From aaaeb86e8241f9904b665c43c7c0870a61eeab5c Mon Sep 17 00:00:00 2001 From: "Matteo E. Minnai" Date: Thu, 12 Feb 2026 11:04:43 +0100 Subject: [PATCH 01/44] ESB-950 Introduced generic group and role JWT claim mapping --- Dockerfile.tomcat | 4 +- .../KeycloakAuthorizationManager.java | 53 +++--- .../mapping/DynamicMappingElement.java | 2 +- .../services/mapping/DynamicMappingKind.java | 3 +- .../KeycloakAuthorizationManagerTest.java | 158 +++++++++++------- 5 files changed, 129 insertions(+), 91 deletions(-) diff --git a/Dockerfile.tomcat b/Dockerfile.tomcat index fc3a0bb4f..4b4e9b42b 100644 --- a/Dockerfile.tomcat +++ b/Dockerfile.tomcat @@ -10,8 +10,8 @@ LABEL name="Entando App" \ summary="Entando Application" \ description="This Entando app engine application provides APIs and composition for Entando applications" -COPY target/generated-resources/licenses /licenses -COPY target/generated-resources/licenses.xml / +###COPY target/generated-resources/licenses /licenses +###COPY target/generated-resources/licenses.xml / COPY --chown=185:0 webapp/target/*.war /usr/local/tomcat/webapps/ 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 57d277d6e..aa5c9749a 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,11 +1,11 @@ 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.GROUPCLAIM; import static org.entando.entando.keycloak.services.mapping.DynamicMappingKind.GROUPROLE; import static org.entando.entando.keycloak.services.mapping.DynamicMappingKind.ROLE; +import static org.entando.entando.keycloak.services.mapping.DynamicMappingKind.ROLECLAIM; import com.agiletec.aps.system.common.AbstractService; import com.agiletec.aps.system.services.authorization.Authorization; @@ -138,12 +138,12 @@ private boolean isValid(DynamicMappingElement elem) { log.error("invalid dynamic mapping element, 'kind' is blank"); return false; } - if (StringUtils.isBlank(elem.attribute) && elem.kind != CLIENTROLE) { + if (StringUtils.isBlank(elem.attribute) && (elem.kind != ROLECLAIM && elem.kind != GROUPCLAIM)) { 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"); + if (StringUtils.isBlank(elem.path) && elem.kind == ROLECLAIM) { + log.error("invalid dynamic mapping element, 'path' is blank for ROLECLAIM kind"); return false; } if (StringUtils.isBlank(elem.separator) && elem.kind == GROUPROLE) { @@ -157,16 +157,10 @@ public void processNewUser(final UserDetails user, final String token, final boo 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); + // process path role claims, if any... + if (StringUtils.isNotBlank(token) && !jwtMappings.isEmpty()) { + for (DynamicMappingElement cur: jwtMappings) { + processJwtClaimAttributes(user, token, decode, cur); } } // ...then process attributes coming from the user profile, if needed @@ -185,33 +179,36 @@ public void processNewUser(final UserDetails user, final String token, final boo * @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 + * @param claimMapper the mapping configuration */ - private void processRoleClaimAttributes(UserDetails user, String token, boolean decode, DynamicMappingElement tokenMapper) { + private void processJwtClaimAttributes(final UserDetails user, final String token, final boolean decode, final DynamicMappingElement claimMapper) { 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); + // root.at("/realm_access/roles") + final String jwtPath = "/".concat(claimMapper.path.replace(".", "/")); + JsonNode authNode = root + .at(jwtPath); - JsonNode roleNode = root - .path("resource_access") - .path(tokenMapper.client) - .path("roles"); - - if (roleNode == null) { + if (authNode == null) { return ; } - List roles = StreamSupport.stream(roleNode.spliterator(), false) + List authorizations = StreamSupport.stream(authNode.spliterator(), false) .map(JsonNode::asText) .collect(Collectors.toList()); if (user instanceof KeycloakUser) { - finalizeRoleAssociation((KeycloakUser) user, tokenMapper, roles); + if (claimMapper.kind == ROLECLAIM) { + finalizeRoleAssociation((KeycloakUser) user, claimMapper, authorizations); + } else { + finalizeGroupAssociation((KeycloakUser) user, claimMapper, authorizations); + } } } catch (Exception e) { - log.error("error importing client role into Entando roles", e); + log.error("error importing path role into Entando roles", e); } } @@ -421,6 +418,10 @@ private void doProcessGroup(KeycloakUser user, DynamicMappingElement elem) { if (authorizations == null) { return; } + finalizeGroupAssociation(user, elem, authorizations); + } + + private void finalizeGroupAssociation(KeycloakUser user, DynamicMappingElement elem, List authorizations) { for (String kca: authorizations) { try { // skip if the role is already mapped diff --git a/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/DynamicMappingElement.java b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/DynamicMappingElement.java index bddf07f61..5eb164f72 100644 --- a/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/DynamicMappingElement.java +++ b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/DynamicMappingElement.java @@ -9,7 +9,7 @@ public class DynamicMappingElement { public String injectTo; public boolean persist; public String separator; // FOR GROUPROLE ONLY - public String client; // FOR CLIENTROLE ONLY + public String path; // FOR *CLAIM ONLY public String toString() { return "DynamicMappingElement(enabled=" + this.enabled + ", attribute=" + this.attribute + ", kind=" diff --git a/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/DynamicMappingKind.java b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/DynamicMappingKind.java index 3cce689f3..52a4eae0f 100644 --- a/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/DynamicMappingKind.java +++ b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/DynamicMappingKind.java @@ -10,7 +10,8 @@ public enum DynamicMappingKind { GROUP("group", false), ROLE("role", false), GROUPROLE("grouprole", false), - CLIENTROLE("clientrole", true); + ROLECLAIM("roleclaim", true), + GROUPCLAIM("groupclaim", true); private final String kind; @Getter diff --git a/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java b/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java index c9ccce4b1..fe88ff893 100644 --- a/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java +++ b/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java @@ -156,7 +156,7 @@ void testDynamicConfigurationRoleOnLoginFromJwt() throws Exception { when(roleManager.getRole(anyString())).thenReturn(null); when(userDetails.getAuthorizations()).thenReturn(new ArrayList<>()); when(userDetails.getUsername()).thenReturn("testuser"); - when(configManager.getConfigItem(anyString())).thenReturn(XML_CLIENT_ROLE); + when(configManager.getConfigItem(anyString())).thenReturn(XML_ROLE_CLAIM); manager.init(); @@ -164,16 +164,40 @@ void testDynamicConfigurationRoleOnLoginFromJwt() throws Exception { manager.processNewUser(userDetails, JWT, false); - verify(authorizationManager, times(1)).addUserAuthorization(eq("testuser"), authCaptor.capture()); + verify(authorizationManager, times(4)).addUserAuthorization(eq("testuser"), authCaptor.capture()); - assertThat(authCaptor.getValue().getRole().getName()).isEqualTo("generico"); +// assertThat(authCaptor.getValue().getRole().getName()).isEqualTo("generico"); + assertThat(authCaptor.getAllValues()) + .extracting(a -> a.getRole().getName()) + .containsOnly("generico","offline_access", "uma_authorization", "default-roles-entando"); assertThat(authCaptor.getValue().getGroup()).isNull(); } + @Test + void testDynamicConfigurationGroupOnLoginFromJwt() throws Exception { + when(configuration.getDefaultAuthorizations()).thenReturn(null); + when(userDetails.getAuthorizations()).thenReturn(new ArrayList<>()); + when(userDetails.getUsername()).thenReturn("testuser"); + when(configManager.getConfigItem(anyString())).thenReturn(XML_GROUP_CLAIM); + + manager.init(); + + final ArgumentCaptor authCaptor = ArgumentCaptor.forClass(Authorization.class); + + manager.processNewUser(userDetails, JWT, false); + + verify(authorizationManager, times(2)).addUserAuthorization(eq("testuser"), authCaptor.capture()); + + assertThat(authCaptor.getAllValues()) + .extracting(a -> a.getGroup().getName()) + .containsExactlyInAnyOrder("Gruppo-Microsoft-Importato", "altro-gruppo"); + assertThat(authCaptor.getValue().getRole()).isNull(); + } + @Test void testDynamicConfigurationRoleOnLoginWithWrongJwt() throws Exception { when(configuration.getDefaultAuthorizations()).thenReturn(null); - when(configManager.getConfigItem(anyString())).thenReturn(XML_CLIENT_ROLE); + when(configManager.getConfigItem(anyString())).thenReturn(XML_ROLE_CLAIM); manager.init(); @@ -494,11 +518,20 @@ private Authorization authorization(final String groupName, final String roleNam + " " + ""; - private static final String XML_CLIENT_ROLE = "" + private static final String XML_ROLE_CLAIM = "" + " " + " true" - + " sim730" - + " CLIENTROLE" + + " realm_access.roles" + + " ROLECLAIM" + + " true" + + " " + + ""; + + private static final String XML_GROUP_CLAIM = "" + + " " + + " true" + + " groups" + + " GROUPCLAIM" + " true" + " " + ""; @@ -533,7 +566,7 @@ private Authorization authorization(final String groupName, final String roleNam + " " + " false" + " AD_ROLE" - + " CLIENTROLE" // no client + + " ROLECLAIM" // no path + " true" + " " @@ -549,81 +582,84 @@ private Authorization authorization(final String groupName, final String roleNam private static final String JWT_NO_ROLE = "{\n" + " \"header\" : {\n" - + " \"alg\" : \"RS256\",\n" - + " \"typ\" : \"JWT\",\n" + + " \"alg\" : \"RS256\"," + + " \"typ\" : \"JWT\"," + " \"kid\" : \"l09Wlf_NY_dmMORYBjkr7deFVGVJ5TRLHW1p7DIT1ds\"\n" - + " },\n" + + " }," + " \"payload\" : {\n" - + " \"exp\" : 1768319443,\n" - + " \"iat\" : 1768319143,\n" - + " \"auth_time\" : 1768319142,\n" - + " \"jti\" : \"e64ed1da-aa8c-488f-be10-09e0c2f580c3\",\n" - + " \"iss\" : \"https://localhost:8080/auth/realms/entando\",\n" - + " \"aud\" : [ \"sim730\", \"account\" ],\n" - + " \"sub\" : \"5e7213c6-ad81-4094-bb24-fead709b05af\",\n" - + " \"typ\" : \"Bearer\",\n" - + " \"azp\" : \"entando-web\",\n" - + " \"nonce\" : \"6a9f89c2-c904-4e9e-80cb-e8c1ccddd1e0\",\n" - + " \"session_state\" : \"0503e261-d522-41b6-8096-1debdd2c86e7\",\n" - + " \"acr\" : \"1\",\n" - + " \"allowed-origins\" : [ \"https://localhost:8080\", \"*\" ],\n" + + " \"exp\" : 1768319443," + + " \"iat\" : 1768319143," + + " \"auth_time\" : 1768319142," + + " \"jti\" : \"e64ed1da-aa8c-488f-be10-09e0c2f580c3\"," + + " \"iss\" : \"https://localhost:8080/auth/realms/entando\"," + + " \"aud\" : [ \"sim730\", \"account\" ]," + + " \"sub\" : \"5e7213c6-ad81-4094-bb24-fead709b05af\"," + + " \"typ\" : \"Bearer\"," + + " \"azp\" : \"entando-web\"," + + " \"nonce\" : \"6a9f89c2-c904-4e9e-80cb-e8c1ccddd1e0\"," + + " \"session_state\" : \"0503e261-d522-41b6-8096-1debdd2c86e7\"," + + " \"acr\" : \"1\"," + + " \"allowed-origins\" : [ \"https://localhost:8080\", \"*\" ]," + " \"realm_access\" : {\n" + " \"roles\" : [ \"offline_access\", \"uma_authorization\", \"default-roles-entando\" ]\n" - + " },\n" + + " }," + " \"resource_access\" : {\n" + " \"aclient\" : {\n" + " \"roles\" : [ \"generico\" ]\n" - + " },\n" + + " }," + " \"account\" : {\n" + " \"roles\" : [ \"manage-account\", \"manage-account-links\", \"view-profile\" ]\n" + " }\n" - + " },\n" - + " \"scope\" : \"openid profile email\",\n" - + " \"sid\" : \"0503e261-d522-41b6-8096-1debdd2c86e7\",\n" - + " \"email_verified\" : false,\n" - + " \"name\" : \"User lastname\",\n" - + " \"preferred_username\" : \"user@email.it\",\n" - + " \"given_name\" : \"User\",\n" - + " \"family_name\" : \"lastname\",\n" - + " \"email\" : \"user@email.it\",\n" + + " }," + + " \"scope\" : \"openid profile email\"," + + " \"sid\" : \"0503e261-d522-41b6-8096-1debdd2c86e7\"," + + " \"email_verified\" : false," + + " \"name\" : \"User lastname\"," + + " \"preferred_username\" : \"user@email.it\"," + + " \"given_name\" : \"User\"," + + " \"family_name\" : \"lastname\"," + + " \"email\" : \"user@email.it\"," + " \"miei_ruoli_custom\" : [ \"offline_access\", \"uma_authorization\", \"default-roles-entando\" ]\n" - + " },\n" + + " }," + " \"signature\" : \"dLENSPEPw\"\n" + "}"; private static final String JWT = "{\n" - + " \"exp\" : 1768319443,\n" - + " \"iat\" : 1768319143,\n" - + " \"auth_time\" : 1768319142,\n" - + " \"jti\" : \"e64ed1da-aa8c-488f-be10-09e0c2f580c3\",\n" - + " \"iss\" : \"https://localhost:8080/auth/realms/entando\",\n" - + " \"aud\" : [ \"sim730\", \"account\" ],\n" - + " \"sub\" : \"5e7213c6-ad81-4094-bb24-fead709b05af\",\n" - + " \"typ\" : \"Bearer\",\n" - + " \"azp\" : \"entando-web\",\n" - + " \"nonce\" : \"6a9f89c2-c904-4e9e-80cb-e8c1ccddd1e0\",\n" - + " \"session_state\" : \"0503e261-d522-41b6-8096-1debdd2c86e7\",\n" - + " \"acr\" : \"1\",\n" - + " \"allowed-origins\" : [ \"https://localhost:8080\", \"*\" ],\n" + + " \"exp\" : 1768319443," + + " \"iat\" : 1768319143," + + " \"auth_time\" : 1768319142," + + " \"jti\" : \"e64ed1da-aa8c-488f-be10-09e0c2f580c3\"," + + " \"iss\" : \"https://localhost:8080/auth/realms/entando\"," + + " \"aud\" : [ \"sim730\", \"account\" ]," + + " \"sub\" : \"5e7213c6-ad81-4094-bb24-fead709b05af\"," + + " \"typ\" : \"Bearer\"," + + " \"azp\" : \"entando-web\"," + + " \"nonce\" : \"6a9f89c2-c904-4e9e-80cb-e8c1ccddd1e0\"," + + " \"session_state\" : \"0503e261-d522-41b6-8096-1debdd2c86e7\"," + + " \"acr\" : \"1\"," + + " \"allowed-origins\" : [ \"https://localhost:8080\", \"*\" ]," + " \"realm_access\" : {\n" - + " \"roles\" : [ \"offline_access\", \"uma_authorization\", \"default-roles-entando\" ]\n" - + " },\n" + + " \"roles\" : [ \"offline_access\", \"uma_authorization\", \"default-roles-entando\", \"generico\" ]\n" + + " }," + " \"resource_access\" : {\n" + " \"sim730\" : {\n" + " \"roles\" : [ \"generico\" ]\n" - + " },\n" + + " }," + " \"account\" : {\n" + " \"roles\" : [ \"manage-account\", \"manage-account-links\", \"view-profile\" ]\n" + " }\n" - + " },\n" - + " \"scope\" : \"openid profile email\",\n" - + " \"sid\" : \"0503e261-d522-41b6-8096-1debdd2c86e7\",\n" - + " \"email_verified\" : false,\n" - + " \"name\" : \"User lastname\",\n" - + " \"preferred_username\" : \"user@email.it\",\n" - + " \"given_name\" : \"User\",\n" - + " \"family_name\" : \"lastname\",\n" - + " \"email\" : \"user@email.it\",\n" + + " }," + + " \"scope\" : \"openid profile email\"," + + " \"sid\" : \"0503e261-d522-41b6-8096-1debdd2c86e7\"," + + " \"email_verified\" : false," + + " \"name\" : \"User lastname\"," + + " \"groups\": [\n" + + " \"Gruppo-Microsoft-Importato\", \"altro-gruppo\" " + + " ]," + + " \"preferred_username\" : \"user@email.it\"," + + " \"given_name\" : \"User\"," + + " \"family_name\" : \"lastname\"," + + " \"email\" : \"user@email.it\"," + " \"miei_ruoli_custom\" : [ \"offline_access\", \"uma_authorization\", \"default-roles-entando\" ]\n" + " }"; } From 721c1b078989fe8b6dd105d7cc97905922985a5f Mon Sep 17 00:00:00 2001 From: "Matteo E. Minnai" Date: Thu, 12 Feb 2026 12:08:33 +0100 Subject: [PATCH 02/44] ESB-950 Refactored persistence logic --- .../KeycloakAuthorizationManager.java | 67 ++++++++++++------- .../services/mapping/DynamicMapping.java | 6 ++ .../mapping/DynamicMappingElement.java | 8 +-- .../services/mapping/PersistKind.java | 33 +++++++++ .../KeycloakAuthorizationManagerTest.java | 44 ++++++------ webapp/pom.xml | 13 ++-- 6 files changed, 115 insertions(+), 56 deletions(-) create mode 100644 keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/PersistKind.java 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 aa5c9749a..193bf4e35 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 @@ -38,6 +38,7 @@ 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.mapping.PersistKind; import org.entando.entando.keycloak.services.oidc.model.KeycloakUser; import org.springframework.beans.factory.annotation.Autowired; @@ -330,29 +331,39 @@ private void parseAuthForGroupRole(KeycloakUser user, DynamicMappingElement elem Group group = null; Role role = null; - if (elem.persist) { + + if (elem.persist == PersistKind.AUTH + || elem.persist == PersistKind.FULL) { + // create a group if (StringUtils.isNotBlank(groupName)) { group = findOrCreateGroup(groupName); } - + // create an EMPTY role or use the existing one if (StringUtils.isNotBlank(roleName)) { role = findOrCreateRole(roleName); } + // create the auth authorization = new Authorization(group, role); - persistAuthIfMissing(user, authorization); + // persist the association between user and auth + if (elem.persist == PersistKind.FULL) { + persistAuthIfMissing(user, authorization); + } } else { + // create a group on the fly if (StringUtils.isNotBlank(groupName)) { group = new Group(); group.setName(groupName); group.setDescription("sys:" + groupName); } + // assign an existing ROLE if (StringUtils.isNotBlank(roleName)) { // make sure all the permissions are assigned to the current role role = roleManager.getRole(roleName); } authorization = new Authorization(group, role); } + // finally user.addAuthorization(authorization); } @@ -372,6 +383,8 @@ private void finalizeRoleAssociation(KeycloakUser user, DynamicMappingElement el } for (String kca: authorizations) { try { + Authorization auth; + // skip if the group is already mapped if (user.getAuthorizations() .stream() @@ -380,9 +393,18 @@ private void finalizeRoleAssociation(KeycloakUser user, DynamicMappingElement el log.debug("Role {} already assigned to user {}", kca, user.getUsername()); return; } - final Authorization auth = elem.persist - ? createPersistedRoleAuthorization(user, kca) - : createTransientRoleAuthorization(kca); + + if (elem.persist == PersistKind.AUTH || elem.persist == PersistKind.FULL) { + final Role role = findOrCreateRole(kca); + + auth = new Authorization(null, role); + + if (elem.persist == PersistKind.FULL) { + persistAuthIfMissing(user, auth); + } + } else { + auth = createTransientRoleAuthorization(kca); + } user.addAuthorization(auth); log.info("Successfully assigned role {} to user {}", kca, user.getUsername()); @@ -392,18 +414,12 @@ private void finalizeRoleAssociation(KeycloakUser user, DynamicMappingElement el } } - 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); + role.setDescription(roleName); } return new Authorization(null, role); } @@ -424,6 +440,8 @@ private void doProcessGroup(KeycloakUser user, DynamicMappingElement elem) { private void finalizeGroupAssociation(KeycloakUser user, DynamicMappingElement elem, List authorizations) { for (String kca: authorizations) { try { + Authorization auth; + // skip if the role is already mapped if (user.getAuthorizations() .stream() @@ -432,9 +450,18 @@ private void finalizeGroupAssociation(KeycloakUser user, DynamicMappingElement e log.debug("Group {} already assigned to user {}", kca, user.getUsername()); return; } - final Authorization auth = elem.persist - ? createPersistedGroupAuthorization(user, kca) - : createTransientGroupAuthorization(kca); + + if (elem.persist == PersistKind.AUTH || elem.persist == PersistKind.FULL) { + final Group group = findOrCreateGroup(kca); + + auth = new Authorization(group, null); + + if (elem.persist == PersistKind.FULL) { + persistAuthIfMissing(user, auth); + } + } else { + auth = createTransientGroupAuthorization(kca); + } user.addAuthorization(auth); log.info("Successfully assigned group {} to user {}", kca, user.getUsername()); @@ -444,16 +471,10 @@ private void finalizeGroupAssociation(KeycloakUser user, DynamicMappingElement 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); + group.setDescription(groupName); return new Authorization(group, null); } diff --git a/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/DynamicMapping.java b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/DynamicMapping.java index b3b30ecdd..0184a403d 100644 --- a/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/DynamicMapping.java +++ b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/DynamicMapping.java @@ -9,7 +9,13 @@ @JsonInclude(JsonInclude.Include.NON_EMPTY) public class DynamicMapping { + // if NOT null, import only authorizations containing this value + public String id; + @JacksonXmlElementWrapper(useWrapping = false) public List mapping; + @JacksonXmlElementWrapper(useWrapping = false) + public List ignore; + } diff --git a/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/DynamicMappingElement.java b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/DynamicMappingElement.java index 5eb164f72..40bfdbbb2 100644 --- a/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/DynamicMappingElement.java +++ b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/DynamicMappingElement.java @@ -6,14 +6,8 @@ public class DynamicMappingElement { public boolean enabled; public String attribute; public DynamicMappingKind kind; - public String injectTo; - public boolean persist; + public PersistKind persist; public String separator; // FOR GROUPROLE ONLY public String path; // FOR *CLAIM ONLY - public String toString() { - return "DynamicMappingElement(enabled=" + this.enabled + ", attribute=" + this.attribute + ", kind=" - + this.kind + ", injectTo=" + this.injectTo - + ", persist=" + this.persist + ", separator=\" + this.separator + \")"; - } } diff --git a/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/PersistKind.java b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/PersistKind.java new file mode 100644 index 000000000..a0ab9f09a --- /dev/null +++ b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/PersistKind.java @@ -0,0 +1,33 @@ +package org.entando.entando.keycloak.services.mapping; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import java.util.Arrays; + +public enum PersistKind { + NONE("none"), + // create the group (or empty role) but do NOT persist the user association + AUTH("auth"), + // create the group (or empty role) and persist the association with the logging-in user + FULL("full"); + + private final String kind; + + PersistKind(String kind) { + this.kind = kind; + } + + @JsonValue + public String getXmlValue() { + return kind; + } + + @JsonCreator + public static PersistKind fromValue(String value) { + return Arrays.stream(values()) + .filter(k -> k.kind.equalsIgnoreCase(value)) + .findFirst() + .orElseThrow(() -> + new IllegalArgumentException("Unknown DynamicMappingKind: " + value)); + } +} diff --git a/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java b/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java index fe88ff893..41670ac55 100644 --- a/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java +++ b/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java @@ -413,20 +413,20 @@ private Authorization authorization(final String groupName, final String roleNam + " true" + " AD_ROLE" + " ROLE" - + " true" + + " FULL" + " " + " " + " false" + " AD_GROUP" + " GROUP" - + " true" + + " FULL" + " " + " " + " false" + " AD_GROUPROLE" + " GROUPROLE" + " _r_" - + " true" + + " FULL" + " " + ""; @@ -435,20 +435,20 @@ private Authorization authorization(final String groupName, final String roleNam + " false" + " AD_ROLE" + " ROLE" - + " true" + + " FULL" + " " + " " + " true" + " AD_GROUP" + " GROUP" - + " true" + + " FULL" + " " + " " + " false" + " AD_GROUPROLE" + " GROUPROLE" + " _r_" - + " true" + + " FULL" + " " + ""; @@ -457,20 +457,20 @@ private Authorization authorization(final String groupName, final String roleNam + " false" + " AD_ROLE" + " ROLE" - + " true" + + " FULL" + " " + " " + " true" + " AD_GROUP" + " GROUP" - + " false" + + " NONE" + " " + " " + " false" + " AD_GROUPROLE" + " GROUPROLE" + " _r_" - + " true" + + " FULL" + " " + ""; @@ -479,20 +479,20 @@ private Authorization authorization(final String groupName, final String roleNam + " false" + " AD_ROLE" + " ROLE" - + " true" + + " FULL" + " " + " " + " false" + " AD_GROUP" + " GROUP" - + " true" + + " FULL" + " " + " " + " true" + " AD_GROUPROLE" + " GROUPROLE" + " _r_" - + " true" + + " FULL" + " " + ""; @@ -501,20 +501,20 @@ private Authorization authorization(final String groupName, final String roleNam + " false" + " AD_ROLE" + " ROLE" - + " true" + + " FULL" + " " + " " + " false" + " AD_GROUP" + " GROUP" - + " true" + + " FULL" + " " + " " + " true" + " AD_GROUPROLE" + " GROUPROLE" + " _r_" - + " false" + + " NONE" + " " + ""; @@ -523,7 +523,7 @@ private Authorization authorization(final String groupName, final String roleNam + " true" + " realm_access.roles" + " ROLECLAIM" - + " true" + + " FULL" + " " + ""; @@ -532,7 +532,7 @@ private Authorization authorization(final String groupName, final String roleNam + " true" + " groups" + " GROUPCLAIM" - + " true" + + " FULL" + " " + ""; @@ -547,27 +547,27 @@ private Authorization authorization(final String groupName, final String roleNam + " false" + " AD_ROLE" // + " ROLE" // kind null - + " true" + + " FULL" + " " + " " + " true" + " AD_GROUP" + " GROUP" // unknown - + " true" + + " FULL" + " " + " " + " false" // + " AD_GROUPROLE" // attribute null + " GROUPROLE" + " _r_" - + " true" + + " FULL" + " " + " " + " false" + " AD_ROLE" + " ROLECLAIM" // no path - + " true" + + " FULL" + " " + " " @@ -575,7 +575,7 @@ private Authorization authorization(final String groupName, final String roleNam + " AD_GROUPROLE" + " GROUPROLE" // + " _r_" // separator null - + " true" + + " FULL" + " " + ""; diff --git a/webapp/pom.xml b/webapp/pom.xml index a80615bd2..debf08294 100644 --- a/webapp/pom.xml +++ b/webapp/pom.xml @@ -48,11 +48,16 @@ 2.5.0 - false + true http://localhost:8081/auth - entando - entando-app - b4b34472-9926-4753-9db8-a50f152df3da + entando-development + entando-core + 930837f0-95b2-4eeb-b303-82a56cac76e6 + + + + + entando-web From 8b9994a06cbb3d1021dcdaa522aae8383e552e26 Mon Sep 17 00:00:00 2001 From: "Matteo E. Minnai" Date: Fri, 13 Feb 2026 07:23:19 +0100 Subject: [PATCH 03/44] ESB-950 Improved scalability --- .../resources/liquibase/changeSetServ.xml | 2 + .../serv/00000000000003_schemaServ.xml | 14 + .../KeycloakAuthorizationManager.java | 275 +++++++++--------- .../KeycloakAuthorizationManagerTest.java | 99 ++++++- 4 files changed, 252 insertions(+), 138 deletions(-) create mode 100644 engine/src/main/resources/liquibase/serv/00000000000003_schemaServ.xml diff --git a/engine/src/main/resources/liquibase/changeSetServ.xml b/engine/src/main/resources/liquibase/changeSetServ.xml index 4fa32d2d8..4a33efb97 100644 --- a/engine/src/main/resources/liquibase/changeSetServ.xml +++ b/engine/src/main/resources/liquibase/changeSetServ.xml @@ -17,4 +17,6 @@ + + diff --git a/engine/src/main/resources/liquibase/serv/00000000000003_schemaServ.xml b/engine/src/main/resources/liquibase/serv/00000000000003_schemaServ.xml new file mode 100644 index 000000000..552e4361d --- /dev/null +++ b/engine/src/main/resources/liquibase/serv/00000000000003_schemaServ.xml @@ -0,0 +1,14 @@ + + + + + + + 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 193bf4e35..e858c246e 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 @@ -26,6 +26,7 @@ import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReadWriteLock; @@ -83,6 +84,7 @@ public KeycloakAuthorizationManager(final KeycloakConfiguration configuration, */ private transient List profileMappings; private transient List jwtMappings; + private transient List ignore; @Override public void init() throws Exception { @@ -93,18 +95,21 @@ public void init() throws Exception { 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 (dynConf != null) { + if (dynConf.mapping != null) { + + final 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()); + } + ignore = dynConf.ignore; } } if (profileMappings != null) { @@ -246,34 +251,45 @@ private void assignGroupToUser(final String authorization, final UserDetails use } } - private synchronized Group findOrCreateGroup(final String groupName) { + private Group findOrCreateGroup(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); - } + + if (group != null) { + return group; + } + + Group newGroup = new Group(); + newGroup.setName(groupName); + newGroup.setDescription(groupName); + + try { + groupManager.addGroup(newGroup); + return newGroup; + } catch (EntException e) { + log.debug("Error persisting group {} ( It might have been already added by another process).", + groupName); + return groupManager.getGroup(groupName); } - return group; } - private synchronized Role findOrCreateRole(final String roleName) { + private 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); - } + + if (role != null) { + return role; + } + + role = new Role(); + role.setName(roleName); + role.setDescription(roleName); + try { + roleManager.addRole(role); + return role; + } catch (EntException e) { + log.debug("Error persisting role {} (It might have been already added by another process).", + roleName); + return roleManager.getRole(roleName); } - return role; } /** @@ -281,7 +297,7 @@ private synchronized Role findOrCreateRole(final String roleName) { * keycloak * @param user the currently logged user */ - private synchronized void processProfileAttributes(final KeycloakUser user) { + private void processProfileAttributes(final KeycloakUser user) { profileMappings.forEach(m -> { if (m.kind == ROLE) { doProcessRole(user, m); @@ -326,47 +342,32 @@ private void parseAuthForGroupRole(KeycloakUser user, DynamicMappingElement elem final String groupName = tokens[0]; final String roleName = tokens[1]; + final boolean shouldPersistAuth = (elem.persist == PersistKind.AUTH || elem.persist == PersistKind.FULL); - Authorization authorization; - Group group = null; - Role role = null; + Group group = shouldPersistAuth + ? findOrCreateGroup(groupName) + : createTransientGroup(groupName); + Role role = shouldPersistAuth + ? findOrCreateRole(roleName) + : roleManager.getRole(roleName); - if (elem.persist == PersistKind.AUTH - || elem.persist == PersistKind.FULL) { - // create a group - if (StringUtils.isNotBlank(groupName)) { - group = findOrCreateGroup(groupName); - } - // create an EMPTY role or use the existing one - if (StringUtils.isNotBlank(roleName)) { - role = findOrCreateRole(roleName); - } - // create the auth - authorization = new Authorization(group, role); + Authorization authorization = new Authorization(group, role); - // persist the association between user and auth - if (elem.persist == PersistKind.FULL) { - persistAuthIfMissing(user, authorization); - } - } else { - // create a group on the fly - if (StringUtils.isNotBlank(groupName)) { - group = new Group(); - group.setName(groupName); - group.setDescription("sys:" + groupName); - } - // assign an existing ROLE - if (StringUtils.isNotBlank(roleName)) { - // make sure all the permissions are assigned to the current role - role = roleManager.getRole(roleName); - } - authorization = new Authorization(group, role); + if (elem.persist == PersistKind.FULL) { + persistAuthIfMissing(user, authorization); } - // finally + user.addAuthorization(authorization); } + private Group createTransientGroup(String groupName) { + Group group = new Group(); + group.setName(groupName); + group.setDescription("sys:" + groupName); + return group; + } + /** * Process the dynamic Role authorization for the given user * @param user the currently logging-in user @@ -378,42 +379,37 @@ private void doProcessRole(KeycloakUser user, DynamicMappingElement elem) { } private void finalizeRoleAssociation(KeycloakUser user, DynamicMappingElement elem, List authorizations) { - if (authorizations == null) { - return; - } - for (String kca: authorizations) { + if (authorizations == null) return; + + for (String roleName : authorizations) { try { - Authorization auth; - - // 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; + if (isRoleAlreadyAssigned(user, roleName)) { + log.debug("Role {} already assigned to user {}", roleName, user.getUsername()); + continue; } - if (elem.persist == PersistKind.AUTH || elem.persist == PersistKind.FULL) { - final Role role = findOrCreateRole(kca); + Authorization auth = (elem.persist == PersistKind.AUTH || elem.persist == PersistKind.FULL) + ? new Authorization(null, findOrCreateRole(roleName)) + : createTransientRoleAuthorization(roleName); - auth = new Authorization(null, role); - - if (elem.persist == PersistKind.FULL) { - persistAuthIfMissing(user, auth); - } - } else { - auth = createTransientRoleAuthorization(kca); + if (elem.persist == PersistKind.FULL) { + persistAuthIfMissing(user, auth); } user.addAuthorization(auth); - log.info("Successfully assigned role {} to user {}", kca, user.getUsername()); + log.info("Successfully assigned role {} to user {}", roleName, user.getUsername()); + } catch (Exception e) { - log.error("Error processing dynamic role '{}' for user {}", kca , user.getUsername(), e); + log.error("Error processing dynamic role '{}' for user {}", roleName, user.getUsername(), e); } } } + private boolean isRoleAlreadyAssigned(KeycloakUser user, String roleName) { + return user.getAuthorizations().stream() + .anyMatch(a -> a.getRole() != null && roleName.equals(a.getRole().getName())); + } + private Authorization createTransientRoleAuthorization(String roleName) { Role role = roleManager.getRole(roleName); if (role == null) { @@ -438,39 +434,38 @@ private void doProcessGroup(KeycloakUser user, DynamicMappingElement elem) { } private void finalizeGroupAssociation(KeycloakUser user, DynamicMappingElement elem, List authorizations) { - for (String kca: authorizations) { + if (authorizations == null) return; + + for (String groupName : authorizations) { try { - Authorization auth; - - // 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; + if (isGroupAlreadyAssigned(user, groupName)) { + log.debug("Group {} already assigned to user {}", groupName, user.getUsername()); + continue; } - if (elem.persist == PersistKind.AUTH || elem.persist == PersistKind.FULL) { - final Group group = findOrCreateGroup(kca); + Authorization auth = (elem.persist == PersistKind.AUTH || elem.persist == PersistKind.FULL) + ? new Authorization(findOrCreateGroup(groupName), null) + : createTransientGroupAuthorization(groupName); - auth = new Authorization(group, null); - - if (elem.persist == PersistKind.FULL) { - persistAuthIfMissing(user, auth); - } - } else { - auth = createTransientGroupAuthorization(kca); + // optionally persist + if (elem.persist == PersistKind.FULL) { + persistAuthIfMissing(user, auth); } user.addAuthorization(auth); - log.info("Successfully assigned group {} to user {}", kca, user.getUsername()); + log.info("Successfully assigned group {} to user {}", groupName, user.getUsername()); + } catch (Exception e) { - log.error("Error processing dynamic group for user {}", user.getUsername(), e); + log.error("Error processing dynamic group '{}' for user {}", groupName, user.getUsername(), e); } } } + private boolean isGroupAlreadyAssigned(KeycloakUser user, String groupName) { + return user.getAuthorizations().stream() + .anyMatch(a -> a.getGroup() != null && groupName.equals(a.getGroup().getName())); + } + private Authorization createTransientGroupAuthorization(String groupName) { Group group = new Group(); group.setName(groupName); @@ -498,34 +493,42 @@ private static List processUserProfileAttribute(KeycloakUser user, Dynam } /** - * To avoid creating duplicate records, we are forced to check if the authorization already exists. + * To avoid creating duplicate records, we are forced to check if the authorization already exists. Synchronized here + * is needed to protect against concurrent modifications and ensure atomicity of the operation inside the same POD. + * In a replicated environment, there is still the possibility to create multiple, identical associations, depending + * on the database vendor when the role and/or group are null. * @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); + final String username = user.getUsername(); + final List existing = authorizationManager.getUserAuthorizations(username); + + final String targetGroupName = (null != auth.getGroup()) ? auth.getGroup().getName() : null; + final String targetRoleName = (null != auth.getRole()) ? auth.getRole().getName() : null; + + boolean alreadyExists = existing.stream().anyMatch(a -> { + String existingGroupName = (null != a.getGroup()) ? a.getGroup().getName() : null; + String existingRoleName = (null != a.getRole()) ? a.getRole().getName() : null; + + return Objects.equals(existingGroupName, targetGroupName) && + Objects.equals(existingRoleName, targetRoleName); + }); + + if (!alreadyExists) { + log.debug("Persisting new authorization for user '{}': group={}, role={}", + username, targetGroupName, targetRoleName); + try { + authorizationManager.addUserAuthorization(username, auth); + } catch (EntException e) { + log.debug("Error persisting authorization for user '{}': group={}, role={}. " + + "It might have been already added by another process.", + username, targetGroupName, targetRoleName); + } + } else { + log.debug("Authorization already exists for user '{}': group={}, role={}. Skipping persistence.", + username, targetGroupName, targetRoleName); } } diff --git a/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java b/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java index 41670ac55..cdece0c52 100644 --- a/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java +++ b/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java @@ -165,14 +165,36 @@ void testDynamicConfigurationRoleOnLoginFromJwt() throws Exception { manager.processNewUser(userDetails, JWT, false); verify(authorizationManager, times(4)).addUserAuthorization(eq("testuser"), authCaptor.capture()); - -// assertThat(authCaptor.getValue().getRole().getName()).isEqualTo("generico"); + assertThat(authCaptor.getAllValues()) .extracting(a -> a.getRole().getName()) .containsOnly("generico","offline_access", "uma_authorization", "default-roles-entando"); assertThat(authCaptor.getValue().getGroup()).isNull(); } + @Test + void testDynamicConfigurationRoleOnLoginFromJwtNoPersist() throws Exception { + when(configuration.getDefaultAuthorizations()).thenReturn(null); + when(roleManager.getRole(anyString())).thenReturn(null); + when(userDetails.getAuthorizations()).thenReturn(new ArrayList<>()); + when(userDetails.getUsername()).thenReturn("testuser"); + when(configManager.getConfigItem(anyString())).thenReturn(XML_ROLE_CLAIM_AUTH); + + manager.init(); + + final ArgumentCaptor authCaptor = ArgumentCaptor.forClass(Authorization.class); + + manager.processNewUser(userDetails, JWT, false); + + verify(authorizationManager, never()).addUserAuthorization(eq("testuser"), any()); + verify(userDetails, times(4)).addAuthorization(authCaptor.capture()); + + assertThat(authCaptor.getAllValues()) + .extracting(a -> a.getRole().getName()) + .containsOnly("generico", "offline_access", "uma_authorization", "default-roles-entando"); + assertThat(authCaptor.getValue().getGroup()).isNull(); + } + @Test void testDynamicConfigurationGroupOnLoginFromJwt() throws Exception { when(configuration.getDefaultAuthorizations()).thenReturn(null); @@ -194,6 +216,28 @@ void testDynamicConfigurationGroupOnLoginFromJwt() throws Exception { assertThat(authCaptor.getValue().getRole()).isNull(); } + @Test + void testDynamicConfigurationGroupOnLoginFromJwtNoPersist() throws Exception { + when(configuration.getDefaultAuthorizations()).thenReturn(null); + when(userDetails.getAuthorizations()).thenReturn(new ArrayList<>()); + when(userDetails.getUsername()).thenReturn("testuser"); + when(configManager.getConfigItem(anyString())).thenReturn(XML_GROUP_CLAIM_AUTH); + + manager.init(); + + final ArgumentCaptor authCaptor = ArgumentCaptor.forClass(Authorization.class); + + manager.processNewUser(userDetails, JWT, false); + + verify(authorizationManager, never()).addUserAuthorization(eq("testuser"), any()); + verify(userDetails, times(2)).addAuthorization(authCaptor.capture()); + + assertThat(authCaptor.getAllValues()) + .extracting(a -> a.getGroup().getName()) + .containsExactlyInAnyOrder("Gruppo-Microsoft-Importato", "altro-gruppo"); + assertThat(authCaptor.getValue().getRole()).isNull(); + } + @Test void testDynamicConfigurationRoleOnLoginWithWrongJwt() throws Exception { when(configuration.getDefaultAuthorizations()).thenReturn(null); @@ -400,6 +444,39 @@ void testDynamicConfigurationWrongMapping() throws Exception { verify(authorizationManager, never()).addUserAuthorization(anyString(), any()); } + @Test + void testDynamicConfigurationGroupRoleOnLoginConflict() throws Exception { + Group group = new Group(); + Role role = new Role(); + group.setName("agroup"); + group.setDescription("agroup"); + role.setName("arole"); + role.setDescription("arole"); + + when(authorizationManager.getUserAuthorizations(anyString())).thenReturn(List.of()); + when(configuration.getDefaultAuthorizations()).thenReturn(null); + when(configManager.getConfigItem(anyString())).thenReturn(XML_GROUP_ROLE_CONF); + when(groupManager.getGroup(anyString())).thenReturn(group); + when(roleManager.getRole(anyString())).thenReturn(role); + + UserRepresentation userRepresentation = new UserRepresentation(); + userRepresentation.setAttributes(Map.of("AD_GROUPROLE", List.of("agroup_r_arole"))); + + when(userDetails.getUsername()).thenReturn("testuser"); + when(userDetails.getUserRepresentation()).thenReturn(userRepresentation); + + // Simulate a conflict exception + org.mockito.Mockito.doThrow(new EntException("Conflict")) + .when(authorizationManager).addUserAuthorization(eq("testuser"), any()); + + manager.init(); + + // This should not throw an exception because it's caught in persistAuthIfMissing + manager.processNewUser(userDetails, JWT, true); + + verify(authorizationManager, times(1)).addUserAuthorization(eq("testuser"), any()); + } + private Authorization authorization(final String groupName, final String roleName) { final Group group = new Group(); group.setName(groupName); @@ -527,6 +604,15 @@ private Authorization authorization(final String groupName, final String roleNam + " " + ""; + private static final String XML_ROLE_CLAIM_AUTH = "" + + " " + + " true" + + " realm_access.roles" + + " ROLECLAIM" + + " AUTH" + + " " + + ""; + private static final String XML_GROUP_CLAIM = "" + " " + " true" @@ -536,6 +622,15 @@ private Authorization authorization(final String groupName, final String roleNam + " " + ""; + private static final String XML_GROUP_CLAIM_AUTH = "" + + " " + + " true" + + " groups" + + " GROUPCLAIM" + + " AUTH" + + " " + + ""; + private static final String XML_NO_MAPPING = "" + ""; From fa983eb6f325b9ee2aae78656c6a4f37cddd05ec Mon Sep 17 00:00:00 2001 From: "Matteo E. Minnai" Date: Fri, 13 Feb 2026 12:02:33 +0100 Subject: [PATCH 04/44] ESB-950 Improved scalability and added GROUPROLE jwt mapper --- .../KeycloakAuthorizationManager.java | 177 ++++++++++++++---- .../services/mapping/DynamicMapping.java | 3 - .../mapping/DynamicMappingElement.java | 2 +- .../services/mapping/DynamicMappingKind.java | 3 +- .../KeycloakAuthorizationManagerTest.java | 123 ++++++++++++ 5 files changed, 269 insertions(+), 39 deletions(-) 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 e858c246e..872c5ad40 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 @@ -2,10 +2,10 @@ import static java.util.Optional.ofNullable; import static org.entando.entando.keycloak.services.mapping.DynamicMappingKind.GROUP; -import static org.entando.entando.keycloak.services.mapping.DynamicMappingKind.GROUPCLAIM; import static org.entando.entando.keycloak.services.mapping.DynamicMappingKind.GROUPROLE; import static org.entando.entando.keycloak.services.mapping.DynamicMappingKind.ROLE; import static org.entando.entando.keycloak.services.mapping.DynamicMappingKind.ROLECLAIM; +import static org.entando.entando.keycloak.services.mapping.DynamicMappingKind.ROLEGROUPCLAIM; import com.agiletec.aps.system.common.AbstractService; import com.agiletec.aps.system.services.authorization.Authorization; @@ -16,6 +16,7 @@ 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.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.dataformat.xml.XmlMapper; @@ -39,14 +40,17 @@ 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.mapping.DynamicMappingKind; import org.entando.entando.keycloak.services.mapping.PersistKind; import org.entando.entando.keycloak.services.oidc.model.KeycloakUser; +import org.jspecify.annotations.NonNull; import org.springframework.beans.factory.annotation.Autowired; public class KeycloakAuthorizationManager extends AbstractService { private static final EntLogger log = EntLogFactory.getSanitizedLogger(KeycloakAuthorizationManager.class); - private static final String DEFAULT_SEPARATOR = "_"; + + private static final String DEFAULT_SEPARATOR = "_SEP_"; private final KeycloakConfiguration configuration; private final AuthorizationManager authorizationManager; @@ -144,16 +148,16 @@ private boolean isValid(DynamicMappingElement elem) { log.error("invalid dynamic mapping element, 'kind' is blank"); return false; } - if (StringUtils.isBlank(elem.attribute) && (elem.kind != ROLECLAIM && elem.kind != GROUPCLAIM)) { - log.error("invalid dynamic mapping element, 'attribute' is blank"); + if (StringUtils.isBlank(elem.attribute) && !elem.kind.isJwtMapping()) { + log.error("invalid dynamic mapping element, 'attribute' is blank for kind {}", elem.kind); return false; } - if (StringUtils.isBlank(elem.path) && elem.kind == ROLECLAIM) { - log.error("invalid dynamic mapping element, 'path' is blank for ROLECLAIM kind"); + if (StringUtils.isBlank(elem.path) && elem.kind.isJwtMapping()) { + log.error("invalid dynamic mapping element, 'path' is blank for {} kind", elem.kind); return false; } - if (StringUtils.isBlank(elem.separator) && elem.kind == GROUPROLE) { - log.error("invalid dynamic mapping element, 'separator' is blank for GROUPROLE kind"); + if (StringUtils.isBlank(elem.separator) && (elem.kind == GROUPROLE || elem.kind == ROLEGROUPCLAIM)) { + log.error("invalid dynamic mapping element, 'separator' is blank for {} kind", elem.kind); return false; } return true; @@ -188,33 +192,122 @@ public void processNewUser(final UserDetails user, final String token, final boo * @param claimMapper the mapping configuration */ private void processJwtClaimAttributes(final UserDetails user, final String token, final boolean decode, final DynamicMappingElement claimMapper) { - final String payload = decode ? token.split("\\.")[1] : token; - final String json = decode ? new String(Base64.getUrlDecoder().decode(payload)) : payload; - try { + String json; + if (decode) { + String[] parts = token.split("\\."); + if (parts.length != 3) { + log.error("Invalid JWT token format: expected 3 parts, found {}", parts.length); + return; + } + json = new String(Base64.getUrlDecoder().decode(parts[1])); + } else { + json = token; + } + final JsonNode root = mapper.readTree(json); - // root.at("/realm_access/roles") - final String jwtPath = "/".concat(claimMapper.path.replace(".", "/")); - JsonNode authNode = root - .at(jwtPath); + final String jwtPath = "/" + claimMapper.path.replace(".", "/"); + JsonNode authNode = root.at(jwtPath); + + + if (authNode == null || authNode.isMissingNode() || authNode.isNull()) { + log.debug("Path '{}' not found in JWT claims for user {}", claimMapper.path, user.getUsername()); + return; + } + + List authorizations = new ArrayList<>(); + if (authNode.isArray()) { + //handle an array of authorizations + for (JsonNode node : authNode) { + if (node.isTextual()) { + authorizations.add(node.asText()); + } + } + } else if (authNode.isTextual()) { + // handle the immediate value, just in case + authorizations.add(authNode.asText()); + } else { + log.warn("Unsupported node type for path '{}' in JWT: {}", claimMapper.path, authNode.getNodeType()); + return; - if (authNode == null) { - return ; } - List authorizations = StreamSupport.stream(authNode.spliterator(), false) - .map(JsonNode::asText) - .collect(Collectors.toList()); - if (user instanceof KeycloakUser) { - if (claimMapper.kind == ROLECLAIM) { - finalizeRoleAssociation((KeycloakUser) user, claimMapper, authorizations); + if (user instanceof KeycloakUser && !authorizations.isEmpty()) { + KeycloakUser kcUser = (KeycloakUser) user; + if (claimMapper.kind == DynamicMappingKind.ROLECLAIM) { + finalizeRoleAssociation(kcUser, claimMapper, authorizations); + } else if (claimMapper.kind == DynamicMappingKind.GROUPCLAIM) { + finalizeGroupAssociation(kcUser, claimMapper, authorizations); } else { - finalizeGroupAssociation((KeycloakUser) user, claimMapper, authorizations); + finalizeGroupRoleAssociation(kcUser, claimMapper, authorizations); } } + } catch (IllegalArgumentException e) { + log.error("Error decoding JWT payload for user {}", user.getUsername(), e); + } catch (JsonProcessingException e) { + log.error("Error parsing JWT JSON for user {}", user.getUsername(), e); } catch (Exception e) { - log.error("error importing path role into Entando roles", e); + log.error("Unexpected error importing JWT claims from path '{}' for user {}", + claimMapper.path, user.getUsername(), e); + } + } + + private void finalizeGroupRoleAssociation(KeycloakUser user, DynamicMappingElement elem, List authorizations) { + if (authorizations == null) return; + + for (String candidate : authorizations) { + try { + if (StringUtils.isBlank(candidate)) { + continue; + } + + final String sep = StringUtils.isNotBlank(elem.separator) ? elem.separator : DEFAULT_SEPARATOR; + final String[] tokens = candidate.split(sep); + + if (tokens.length < 2) { + // treat as a role + finalizeRoleAssociation(user, elem, List.of(candidate.trim())); + continue; + } + + final String roleName = tokens[0].trim(); + final String groupName = tokens[1].trim(); + + if (StringUtils.isBlank(roleName)) { + log.warn("Invalid role name extracted from candidate '{}' for user {}", candidate, user.getUsername()); + continue; + } + + // skip if the couple has already been assigned + if (isGroupRoleAlreadyAssigned(user, roleName, groupName)) { + log.debug("Role {} and group {} already assigned to user {}", roleName, groupName, user.getUsername()); + continue; + } + + Authorization auth; + + if (elem.persist == PersistKind.AUTH || elem.persist == PersistKind.FULL) { + final Group group = StringUtils.isNotBlank(groupName) ? findOrCreateGroup(groupName) : null; + final Role role = findOrCreateRole(roleName); + + auth = new Authorization(group, role); + } else { + final Group group = createTransientGroup(groupName); + final Role role = createTransientRole(roleName); + + auth = new Authorization(group, role); + } + + if (elem.persist == PersistKind.FULL) { + persistAuthIfMissing(user, auth); + } + + user.addAuthorization(auth); + log.info("Successfully assigned group-role {} to user {}", candidate, user.getUsername()); + } catch (Exception e) { + log.error("Error processing dynamic group-role '{}' for user {}", candidate, user.getUsername(), e); + } } } @@ -405,19 +498,35 @@ private void finalizeRoleAssociation(KeycloakUser user, DynamicMappingElement el } } - private boolean isRoleAlreadyAssigned(KeycloakUser user, String roleName) { + private boolean isRoleAlreadyAssigned(final KeycloakUser user, final String roleName) { return user.getAuthorizations().stream() .anyMatch(a -> a.getRole() != null && roleName.equals(a.getRole().getName())); } + private boolean isGroupRoleAlreadyAssigned(final KeycloakUser user, final String roleName, final String groupName) { + return user.getAuthorizations().stream() + .anyMatch(a -> { + final String existingRoleName = (a.getRole() != null) ? a.getRole().getName() : null; + final String existingGroupName = (a.getGroup() != null) ? a.getGroup().getName() : null; + + return Objects.equals(existingRoleName, roleName) + && Objects.equals(existingGroupName, groupName); + }); + } + private Authorization createTransientRoleAuthorization(String roleName) { + final Role role = createTransientRole(roleName); + return new Authorization(null, role); + } + + private @NonNull Role createTransientRole(String roleName) { Role role = roleManager.getRole(roleName); if (role == null) { role = new Role(); role.setName(roleName); role.setDescription(roleName); } - return new Authorization(null, role); + return role; } /** @@ -493,15 +602,15 @@ private static List processUserProfileAttribute(KeycloakUser user, Dynam } /** - * To avoid creating duplicate records, we are forced to check if the authorization already exists. Synchronized here - * is needed to protect against concurrent modifications and ensure atomicity of the operation inside the same POD. - * In a replicated environment, there is still the possibility to create multiple, identical associations, depending - * on the database vendor when the role and/or group are null. + * To avoid creating duplicate records, we check if the authorization already exists. + * In a replicated environment or under high concurrency, there is still the possibility + * to attempt to create multiple, identical associations. This is handled by a database + * unique constraint and a try-catch block. * @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 { + private void persistAuthIfMissing(KeycloakUser user, Authorization auth) throws EntException { final String username = user.getUsername(); final List existing = authorizationManager.getUserAuthorizations(username); @@ -522,8 +631,8 @@ private synchronized void persistAuthIfMissing(KeycloakUser user, Authorization try { authorizationManager.addUserAuthorization(username, auth); } catch (EntException e) { - log.debug("Error persisting authorization for user '{}': group={}, role={}. " - + "It might have been already added by another process.", + log.debug("Error persisting authorization for user '{}': group={}, role={} " + + "(it might have been already added by another process).", username, targetGroupName, targetRoleName); } } else { diff --git a/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/DynamicMapping.java b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/DynamicMapping.java index 0184a403d..923121d71 100644 --- a/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/DynamicMapping.java +++ b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/DynamicMapping.java @@ -9,9 +9,6 @@ @JsonInclude(JsonInclude.Include.NON_EMPTY) public class DynamicMapping { - // if NOT null, import only authorizations containing this value - public String id; - @JacksonXmlElementWrapper(useWrapping = false) public List mapping; diff --git a/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/DynamicMappingElement.java b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/DynamicMappingElement.java index 40bfdbbb2..a69fa83c0 100644 --- a/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/DynamicMappingElement.java +++ b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/DynamicMappingElement.java @@ -7,7 +7,7 @@ public class DynamicMappingElement { public String attribute; public DynamicMappingKind kind; public PersistKind persist; - public String separator; // FOR GROUPROLE ONLY + public String separator; // FOR GROUPROLE and GROUPROLECLAIM ONLY public String path; // FOR *CLAIM ONLY } diff --git a/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/DynamicMappingKind.java b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/DynamicMappingKind.java index 52a4eae0f..9416913fc 100644 --- a/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/DynamicMappingKind.java +++ b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/DynamicMappingKind.java @@ -11,7 +11,8 @@ public enum DynamicMappingKind { ROLE("role", false), GROUPROLE("grouprole", false), ROLECLAIM("roleclaim", true), - GROUPCLAIM("groupclaim", true); + GROUPCLAIM("groupclaim", true), + ROLEGROUPCLAIM("ROLEGROUPCLAIM", true); private final String kind; @Getter diff --git a/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java b/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java index cdece0c52..17afd7b35 100644 --- a/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java +++ b/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java @@ -477,6 +477,64 @@ void testDynamicConfigurationGroupRoleOnLoginConflict() throws Exception { verify(authorizationManager, times(1)).addUserAuthorization(eq("testuser"), any()); } + @Test + void testDynamicConfigurationRoleGroupOnLoginFromJwt() throws Exception { + when(configuration.getDefaultAuthorizations()).thenReturn(null); + when(roleManager.getRole(anyString())).thenReturn(null); + when(groupManager.getGroup(anyString())).thenReturn(null); + when(userDetails.getAuthorizations()).thenReturn(new ArrayList<>()); + when(userDetails.getUsername()).thenReturn("testuser"); + when(configManager.getConfigItem(anyString())).thenReturn(XML_ROLEGROUP_CLAIM); + + manager.init(); + + final ArgumentCaptor authCaptor = ArgumentCaptor.forClass(Authorization.class); + + manager.processNewUser(userDetails, JWT_ROLEGROUP, false); + + verify(authorizationManager, times(2)).addUserAuthorization(eq("testuser"), authCaptor.capture()); + + List capturedAuths = authCaptor.getAllValues(); + assertThat(capturedAuths).hasSize(2); + + assertThat(capturedAuths) + .anySatisfy(auth -> { + assertThat(auth.getRole().getName()).isEqualTo("role1"); + assertThat(auth.getGroup().getName()).isEqualTo("group1"); + }) + .anySatisfy(auth -> { + assertThat(auth.getRole().getName()).isEqualTo("role2"); + assertThat(auth.getGroup().getName()).isEqualTo("group2"); + }); + } + + @Test + void testDynamicConfigurationRoleGroupOnLoginFromJwtEdgeCases() throws Exception { + when(configuration.getDefaultAuthorizations()).thenReturn(null); + when(roleManager.getRole(anyString())).thenReturn(null); + when(userDetails.getAuthorizations()).thenReturn(new ArrayList<>()); + when(userDetails.getUsername()).thenReturn("testuser"); + when(configManager.getConfigItem(anyString())).thenReturn(XML_ROLEGROUP_CLAIM); + + manager.init(); + + final ArgumentCaptor authCaptor = ArgumentCaptor.forClass(Authorization.class); + + manager.processNewUser(userDetails, JWT_ROLEGROUP_EDGE, false); + + // "group1" -> tokens.length < 2 -> treated as role "group1" with NO group + // "_SEP_group2" -> tokens = ["", "group2"] -> roleName = "" -> isBlank -> skipped + verify(authorizationManager, times(1)).addUserAuthorization(eq("testuser"), any(Authorization.class)); + + verify(userDetails, times(1)).addAuthorization(authCaptor.capture()); + + List capturedAuths = authCaptor.getAllValues(); + assertThat(capturedAuths).hasSize(1); + + assertThat(capturedAuths.get(0).getRole().getName()).isEqualTo("group1"); + assertThat(capturedAuths.get(0).getGroup()).isNull(); + } + private Authorization authorization(final String groupName, final String roleName) { final Group group = new Group(); group.setName(groupName); @@ -631,6 +689,16 @@ private Authorization authorization(final String groupName, final String roleNam + " " + ""; + private static final String XML_ROLEGROUP_CLAIM = "" + + " " + + " true" + + " realm_access.roles" + + " ROLEGROUPCLAIM" + + " FULL" + + " _SEP_" + + " " + + ""; + private static final String XML_NO_MAPPING = "" + ""; @@ -757,4 +825,59 @@ private Authorization authorization(final String groupName, final String roleNam + " \"email\" : \"user@email.it\"," + " \"miei_ruoli_custom\" : [ \"offline_access\", \"uma_authorization\", \"default-roles-entando\" ]\n" + " }"; + + private static final String JWT_ROLEGROUP = "{\n" + + " \"exp\" : 1768319443," + + " \"iat\" : 1768319143," + + " \"auth_time\" : 1768319142," + + " \"jti\" : \"e64ed1da-aa8c-488f-be10-09e0c2f580c3\"," + + " \"iss\" : \"https://localhost:8080/auth/realms/entando\"," + + " \"aud\" : [ \"sim730\", \"account\" ]," + + " \"sub\" : \"5e7213c6-ad81-4094-bb24-fead709b05af\"," + + " \"typ\" : \"Bearer\"," + + " \"azp\" : \"entando-web\"," + + " \"nonce\" : \"6a9f89c2-c904-4e9e-80cb-e8c1ccddd1e0\"," + + " \"session_state\" : \"0503e261-d522-41b6-8096-1debdd2c86e7\"," + + " \"acr\" : \"1\"," + + " \"allowed-origins\" : [ \"https://localhost:8080\", \"*\" ]," + + " \"realm_access\" : {\n" + + " \"roles\" : [ \"role1_SEP_group1\", \"role2_SEP_group2\" ]\n" + + " }," + + " \"scope\" : \"openid profile email\"," + + " \"sid\" : \"0503e261-d522-41b6-8096-1debdd2c86e7\"," + + " \"email_verified\" : false," + + " \"name\" : \"User lastname\"," + + " \"preferred_username\" : \"user@email.it\"," + + " \"given_name\" : \"User\"," + + " \"family_name\" : \"lastname\"," + + " \"email\" : \"user@email.it\"" + + " }"; + + private static final String JWT_ROLEGROUP_EDGE = "{\n" + + " \"exp\" : 1768319443," + + " \"iat\" : 1768319143," + + " \"auth_time\" : 1768319142," + + " \"jti\" : \"e64ed1da-aa8c-488f-be10-09e0c2f580c3\"," + + " \"iss\" : \"https://localhost:8080/auth/realms/entando\"," + + " \"aud\" : [ \"sim730\", \"account\" ]," + + " \"sub\" : \"5e7213c6-ad81-4094-bb24-fead709b05af\"," + + " \"typ\" : \"Bearer\"," + + " \"azp\" : \"entando-web\"," + + " \"nonce\" : \"6a9f89c2-c904-4e9e-80cb-e8c1ccddd1e0\"," + + " \"session_state\" : \"0503e261-d522-41b6-8096-1debdd2c86e7\"," + + " \"acr\" : \"1\"," + + " \"allowed-origins\" : [ \"https://localhost:8080\", \"*\" ]," + + " \"realm_access\" : {\n" + + " \"roles\" : [ \"group1\", \"_SEP_group2\" ]\n" + + " }," + + " \"scope\" : \"openid profile email\"," + + " \"sid\" : \"0503e261-d522-41b6-8096-1debdd2c86e7\"," + + " \"email_verified\" : false," + + " \"name\" : \"User lastname\"," + + " \"preferred_username\" : \"user@email.it\"," + + " \"given_name\" : \"User\"," + + " \"family_name\" : \"lastname\"," + + " \"email\" : \"user@email.it\"" + + " }"; + } From 48c44765aea694cb440e7c41f75dc4b4550dc98d Mon Sep 17 00:00:00 2001 From: "Matteo E. Minnai" Date: Fri, 13 Feb 2026 12:21:21 +0100 Subject: [PATCH 05/44] ESB-950 Testing --- .../KeycloakAuthorizationManager.java | 2 - .../KeycloakAuthorizationManagerTest.java | 42 ++++++++++++++++++- 2 files changed, 41 insertions(+), 3 deletions(-) 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 872c5ad40..1b15735de 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 @@ -4,7 +4,6 @@ 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 static org.entando.entando.keycloak.services.mapping.DynamicMappingKind.ROLECLAIM; import static org.entando.entando.keycloak.services.mapping.DynamicMappingKind.ROLEGROUPCLAIM; import com.agiletec.aps.system.common.AbstractService; @@ -33,7 +32,6 @@ import java.util.concurrent.locks.ReadWriteLock; import java.util.concurrent.locks.ReentrantReadWriteLock; import java.util.stream.Collectors; -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; diff --git a/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java b/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java index 17afd7b35..ec701e98f 100644 --- a/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java +++ b/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java @@ -522,7 +522,7 @@ void testDynamicConfigurationRoleGroupOnLoginFromJwtEdgeCases() throws Exception manager.processNewUser(userDetails, JWT_ROLEGROUP_EDGE, false); - // "group1" -> tokens.length < 2 -> treated as role "group1" with NO group + // NOTE!!! "group1" -> tokens.length < 2 -> treated as a ROLE "group1" with NO group // "_SEP_group2" -> tokens = ["", "group2"] -> roleName = "" -> isBlank -> skipped verify(authorizationManager, times(1)).addUserAuthorization(eq("testuser"), any(Authorization.class)); @@ -535,6 +535,36 @@ void testDynamicConfigurationRoleGroupOnLoginFromJwtEdgeCases() throws Exception assertThat(capturedAuths.get(0).getGroup()).isNull(); } + @Test + void testDynamicConfigurationRoleGroupOnLoginFromJwtNoPersist() throws Exception { + when(configuration.getDefaultAuthorizations()).thenReturn(null); + when(userDetails.getAuthorizations()).thenReturn(new ArrayList<>()); + when(userDetails.getUsername()).thenReturn("testuser"); + when(configManager.getConfigItem(anyString())).thenReturn(XML_ROLEGROUP_CLAIM_AUTH); + + manager.init(); + + final ArgumentCaptor authCaptor = ArgumentCaptor.forClass(Authorization.class); + + manager.processNewUser(userDetails, JWT_ROLEGROUP, false); + + verify(authorizationManager, never()).addUserAuthorization(eq("testuser"), any()); + verify(userDetails, times(2)).addAuthorization(authCaptor.capture()); + + List capturedAuths = authCaptor.getAllValues(); + assertThat(capturedAuths).hasSize(2); + + assertThat(capturedAuths) + .anySatisfy(auth -> { + assertThat(auth.getRole().getName()).isEqualTo("role1"); + assertThat(auth.getGroup().getName()).isEqualTo("group1"); + }) + .anySatisfy(auth -> { + assertThat(auth.getRole().getName()).isEqualTo("role2"); + assertThat(auth.getGroup().getName()).isEqualTo("group2"); + }); + } + private Authorization authorization(final String groupName, final String roleName) { final Group group = new Group(); group.setName(groupName); @@ -699,6 +729,16 @@ private Authorization authorization(final String groupName, final String roleNam + " " + ""; + private static final String XML_ROLEGROUP_CLAIM_AUTH = "" + + " " + + " true" + + " realm_access.roles" + + " ROLEGROUPCLAIM" + + " AUTH" + + " _SEP_" + + " " + + ""; + private static final String XML_NO_MAPPING = "" + ""; From 263e8ba650f5ffa7b2bbc6a3e00e100c0532591b Mon Sep 17 00:00:00 2001 From: "Matteo E. Minnai" Date: Fri, 13 Feb 2026 12:30:56 +0100 Subject: [PATCH 06/44] ESB-950 Improved SRP by introducing an OIDC mapping support service --- .../KeycloakAuthorizationManager.java | 112 +++--------------- .../services/oidc/OidcMappingService.java | 103 ++++++++++++++++ .../KeycloakAuthorizationManagerTest.java | 4 +- 3 files changed, 123 insertions(+), 96 deletions(-) create mode 100644 keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/oidc/OidcMappingService.java 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 1b15735de..2dd5c31aa 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 @@ -15,15 +15,9 @@ 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.core.JsonProcessingException; -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 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.Objects; @@ -40,6 +34,7 @@ import org.entando.entando.keycloak.services.mapping.DynamicMappingElement; import org.entando.entando.keycloak.services.mapping.DynamicMappingKind; import org.entando.entando.keycloak.services.mapping.PersistKind; +import org.entando.entando.keycloak.services.oidc.OidcMappingService; import org.entando.entando.keycloak.services.oidc.model.KeycloakUser; import org.jspecify.annotations.NonNull; import org.springframework.beans.factory.annotation.Autowired; @@ -55,11 +50,11 @@ public class KeycloakAuthorizationManager extends AbstractService { private final GroupManager groupManager; private final RoleManager roleManager; private final BaseConfigManager configManager; + private final OidcMappingService oidcMappingService; 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(); @@ -71,12 +66,14 @@ public KeycloakAuthorizationManager(final KeycloakConfiguration configuration, final AuthorizationManager authorizationManager, final GroupManager groupManager, final RoleManager roleManager, - final BaseConfigManager configManager1) { + final BaseConfigManager configManager1, + final OidcMappingService oidcMappingService) { this.configuration = configuration; this.authorizationManager = authorizationManager; this.groupManager = groupManager; this.roleManager = roleManager; this.configManager = configManager1; + this.oidcMappingService = oidcMappingService; } /** @@ -190,64 +187,17 @@ public void processNewUser(final UserDetails user, final String token, final boo * @param claimMapper the mapping configuration */ private void processJwtClaimAttributes(final UserDetails user, final String token, final boolean decode, final DynamicMappingElement claimMapper) { - try { - String json; - if (decode) { - String[] parts = token.split("\\."); - if (parts.length != 3) { - log.error("Invalid JWT token format: expected 3 parts, found {}", parts.length); - return; - } - json = new String(Base64.getUrlDecoder().decode(parts[1])); - } else { - json = token; - } - - final JsonNode root = mapper.readTree(json); - final String jwtPath = "/" + claimMapper.path.replace(".", "/"); - JsonNode authNode = root.at(jwtPath); - - - if (authNode == null || authNode.isMissingNode() || authNode.isNull()) { - log.debug("Path '{}' not found in JWT claims for user {}", claimMapper.path, user.getUsername()); - return; - } - - List authorizations = new ArrayList<>(); - if (authNode.isArray()) { - //handle an array of authorizations - for (JsonNode node : authNode) { - if (node.isTextual()) { - authorizations.add(node.asText()); - } - } - } else if (authNode.isTextual()) { - // handle the immediate value, just in case - authorizations.add(authNode.asText()); + final List authorizations = oidcMappingService.extractAuthorizationsFromJwt(token, decode, claimMapper, user.getUsername()); + + if (user instanceof KeycloakUser && !authorizations.isEmpty()) { + KeycloakUser kcUser = (KeycloakUser) user; + if (claimMapper.kind == DynamicMappingKind.ROLECLAIM) { + finalizeRoleAssociation(kcUser, claimMapper, authorizations); + } else if (claimMapper.kind == DynamicMappingKind.GROUPCLAIM) { + finalizeGroupAssociation(kcUser, claimMapper, authorizations); } else { - log.warn("Unsupported node type for path '{}' in JWT: {}", claimMapper.path, authNode.getNodeType()); - return; - - } - - if (user instanceof KeycloakUser && !authorizations.isEmpty()) { - KeycloakUser kcUser = (KeycloakUser) user; - if (claimMapper.kind == DynamicMappingKind.ROLECLAIM) { - finalizeRoleAssociation(kcUser, claimMapper, authorizations); - } else if (claimMapper.kind == DynamicMappingKind.GROUPCLAIM) { - finalizeGroupAssociation(kcUser, claimMapper, authorizations); - } else { - finalizeGroupRoleAssociation(kcUser, claimMapper, authorizations); - } + finalizeGroupRoleAssociation(kcUser, claimMapper, authorizations); } - - } catch (IllegalArgumentException e) { - log.error("Error decoding JWT payload for user {}", user.getUsername(), e); - } catch (JsonProcessingException e) { - log.error("Error parsing JWT JSON for user {}", user.getUsername(), e); - } catch (Exception e) { - log.error("Unexpected error importing JWT claims from path '{}' for user {}", - claimMapper.path, user.getUsername(), e); } } @@ -407,7 +357,7 @@ private void doProcessGroupRole(KeycloakUser user, DynamicMappingElement elem) { DEFAULT_SEPARATOR : elem.separator; try { - final List authorizations = processUserProfileAttribute(user, elem); + final List authorizations = oidcMappingService.extractAuthorizationsFromProfile(user, elem); if (authorizations == null) { return; @@ -465,7 +415,7 @@ private Group createTransientGroup(String groupName) { * @param elem a single dynamic configuration */ private void doProcessRole(KeycloakUser user, DynamicMappingElement elem) { - final List authorizations = processUserProfileAttribute(user, elem); + final List authorizations = oidcMappingService.extractAuthorizationsFromProfile(user, elem); finalizeRoleAssociation(user, elem, authorizations); } @@ -533,7 +483,7 @@ private Authorization createTransientRoleAuthorization(String roleName) { * @param elem a single dynamic configuration */ private void doProcessGroup(KeycloakUser user, DynamicMappingElement elem) { - final List authorizations = processUserProfileAttribute(user, elem); + final List authorizations = oidcMappingService.extractAuthorizationsFromProfile(user, elem); if (authorizations == null) { return; } @@ -586,18 +536,6 @@ private Authorization createTransientGroupAuthorization(String groupName) { * @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 check if the authorization already exists. @@ -645,21 +583,5 @@ private void persistAuthIfMissing(KeycloakUser user, Authorization auth) throws * @param attribute the attribute data * @return the list of the processed attribute tokens */ - protected static List handleKeycloakAttribute(Object attribute) { - if (attribute instanceof List) { - List list = (List) attribute; - return list.stream() - .filter(String.class::isInstance) - .map(String.class::cast) - .flatMap(s -> Arrays.stream(s.split("\\s+"))) - .filter(token -> !token.isBlank()) - .collect(Collectors.toUnmodifiableList()); - } else if (attribute instanceof String) { - return Arrays.stream(((String) attribute).split("\\s+")) - .filter(token -> !token.isBlank()) - .collect(Collectors.toUnmodifiableList()); - } - return new ArrayList<>(); - } } diff --git a/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/oidc/OidcMappingService.java b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/oidc/OidcMappingService.java new file mode 100644 index 000000000..9010bba2a --- /dev/null +++ b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/oidc/OidcMappingService.java @@ -0,0 +1,103 @@ +package org.entando.entando.keycloak.services.oidc; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Base64; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; +import org.entando.entando.ent.util.EntLogging.EntLogFactory; +import org.entando.entando.ent.util.EntLogging.EntLogger; +import org.entando.entando.keycloak.services.mapping.DynamicMappingElement; +import org.entando.entando.keycloak.services.oidc.model.KeycloakUser; +import org.springframework.stereotype.Service; + +@Service +public class OidcMappingService { + + private static final EntLogger log = EntLogFactory.getSanitizedLogger(OidcMappingService.class); + + private final ObjectMapper mapper = new ObjectMapper(); + + public List extractAuthorizationsFromJwt(String token, boolean decode, DynamicMappingElement claimMapper, String username) { + try { + String json; + if (decode) { + String[] parts = token.split("\\."); + if (parts.length != 3) { + log.error("Invalid JWT token format: expected 3 parts, found {}", parts.length); + return Collections.emptyList(); + } + json = new String(Base64.getUrlDecoder().decode(parts[1])); + } else { + json = token; + } + + final JsonNode root = mapper.readTree(json); + final String jwtPath = "/" + claimMapper.path.replace(".", "/"); + JsonNode authNode = root.at(jwtPath); + + if (authNode == null || authNode.isMissingNode() || authNode.isNull()) { + log.debug("Path '{}' not found in JWT claims for user {}", claimMapper.path, username); + return Collections.emptyList(); + } + + List authorizations = new ArrayList<>(); + if (authNode.isArray()) { + for (JsonNode node : authNode) { + if (node.isTextual()) { + authorizations.add(node.asText()); + } + } + } else if (authNode.isTextual()) { + authorizations.add(authNode.asText()); + } else { + log.warn("Unsupported node type for path '{}' in JWT: {}", claimMapper.path, authNode.getNodeType()); + return Collections.emptyList(); + } + return authorizations; + + } catch (IllegalArgumentException e) { + log.error("Error decoding JWT payload for user {}", username, e); + } catch (JsonProcessingException e) { + log.error("Error parsing JWT JSON for user {}", username, e); + } catch (Exception e) { + log.error("Unexpected error importing JWT claims from path '{}' for user {}", + claimMapper.path, username, e); + } + return Collections.emptyList(); + } + + public List extractAuthorizationsFromProfile(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); + } + + protected List handleKeycloakAttribute(Object attribute) { + if (attribute instanceof List) { + List list = (List) attribute; + return list.stream() + .filter(String.class::isInstance) + .map(String.class::cast) + .flatMap(s -> Arrays.stream(s.split("\\s+"))) + .filter(token -> !token.isBlank()) + .collect(Collectors.toUnmodifiableList()); + } else if (attribute instanceof String) { + return Arrays.stream(((String) attribute).split("\\s+")) + .filter(token -> !token.isBlank()) + .collect(Collectors.toUnmodifiableList()); + } + return Collections.emptyList(); + } +} diff --git a/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java b/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java index ec701e98f..4bd3808df 100644 --- a/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java +++ b/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java @@ -24,6 +24,7 @@ import java.util.List; import java.util.Map; import org.entando.entando.ent.exception.EntException; +import org.entando.entando.keycloak.services.oidc.OidcMappingService; import org.entando.entando.keycloak.services.oidc.model.KeycloakUser; import org.entando.entando.keycloak.services.oidc.model.UserRepresentation; import org.junit.jupiter.api.BeforeEach; @@ -42,12 +43,13 @@ class KeycloakAuthorizationManagerTest { @Mock private GroupManager groupManager; @Mock private RoleManager roleManager; @Mock private BaseConfigManager configManager; + private OidcMappingService oidcMappingService = new OidcMappingService(); private KeycloakAuthorizationManager manager; @BeforeEach public void setUp() { - manager = new KeycloakAuthorizationManager(configuration, authorizationManager, groupManager, roleManager, configManager); + manager = new KeycloakAuthorizationManager(configuration, authorizationManager, groupManager, roleManager, configManager, oidcMappingService); } @Test From afb13ddae6f6f402cd36f6813b94d2f86112c81c Mon Sep 17 00:00:00 2001 From: "Matteo E. Minnai" Date: Fri, 13 Feb 2026 12:49:29 +0100 Subject: [PATCH 07/44] ESB-950 Increased class readability --- .../KeycloakAuthorizationManager.java | 139 ++++-------------- .../services/oidc/OidcMappingService.java | 12 ++ 2 files changed, 44 insertions(+), 107 deletions(-) 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 2dd5c31aa..28bfb956d 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 @@ -227,32 +227,7 @@ private void finalizeGroupRoleAssociation(KeycloakUser user, DynamicMappingEleme continue; } - // skip if the couple has already been assigned - if (isGroupRoleAlreadyAssigned(user, roleName, groupName)) { - log.debug("Role {} and group {} already assigned to user {}", roleName, groupName, user.getUsername()); - continue; - } - - Authorization auth; - - if (elem.persist == PersistKind.AUTH || elem.persist == PersistKind.FULL) { - final Group group = StringUtils.isNotBlank(groupName) ? findOrCreateGroup(groupName) : null; - final Role role = findOrCreateRole(roleName); - - auth = new Authorization(group, role); - } else { - final Group group = createTransientGroup(groupName); - final Role role = createTransientRole(roleName); - - auth = new Authorization(group, role); - } - - if (elem.persist == PersistKind.FULL) { - persistAuthIfMissing(user, auth); - } - - user.addAuthorization(auth); - log.info("Successfully assigned group-role {} to user {}", candidate, user.getUsername()); + finalizeAssociation(user, elem, roleName, groupName, candidate); } catch (Exception e) { log.error("Error processing dynamic group-role '{}' for user {}", candidate, user.getUsername(), e); } @@ -383,23 +358,8 @@ private void parseAuthForGroupRole(KeycloakUser user, DynamicMappingElement elem final String groupName = tokens[0]; final String roleName = tokens[1]; - final boolean shouldPersistAuth = (elem.persist == PersistKind.AUTH || elem.persist == PersistKind.FULL); - - Group group = shouldPersistAuth - ? findOrCreateGroup(groupName) - : createTransientGroup(groupName); - Role role = shouldPersistAuth - ? findOrCreateRole(roleName) - : roleManager.getRole(roleName); - - Authorization authorization = new Authorization(group, role); - - if (elem.persist == PersistKind.FULL) { - persistAuthIfMissing(user, authorization); - } - - user.addAuthorization(authorization); + finalizeAssociation(user, elem, roleName, groupName, groupRoleToken, false); } private Group createTransientGroup(String groupName) { @@ -424,34 +384,43 @@ private void finalizeRoleAssociation(KeycloakUser user, DynamicMappingElement el for (String roleName : authorizations) { try { - if (isRoleAlreadyAssigned(user, roleName)) { - log.debug("Role {} already assigned to user {}", roleName, user.getUsername()); - continue; - } - - Authorization auth = (elem.persist == PersistKind.AUTH || elem.persist == PersistKind.FULL) - ? new Authorization(null, findOrCreateRole(roleName)) - : createTransientRoleAuthorization(roleName); - - if (elem.persist == PersistKind.FULL) { - persistAuthIfMissing(user, auth); - } - - user.addAuthorization(auth); - log.info("Successfully assigned role {} to user {}", roleName, user.getUsername()); - + finalizeAssociation(user, elem, roleName, null, roleName); } catch (Exception e) { log.error("Error processing dynamic role '{}' for user {}", roleName, user.getUsername(), e); } } } - private boolean isRoleAlreadyAssigned(final KeycloakUser user, final String roleName) { - return user.getAuthorizations().stream() - .anyMatch(a -> a.getRole() != null && roleName.equals(a.getRole().getName())); + private void finalizeAssociation(KeycloakUser user, DynamicMappingElement elem, String roleName, String groupName, String originalCandidate) throws EntException { + finalizeAssociation(user, elem, roleName, groupName, originalCandidate, true); + } + + private void finalizeAssociation(KeycloakUser user, DynamicMappingElement elem, String roleName, String groupName, String originalCandidate, boolean createRoleIfMissing) throws EntException { + if (isAlreadyAssigned(user, roleName, groupName)) { + log.debug("Role {} and group {} already assigned to user {}", roleName, groupName, user.getUsername()); + return; + } + + Authorization auth; + if (elem.persist == PersistKind.AUTH || elem.persist == PersistKind.FULL) { + final Group group = StringUtils.isNotBlank(groupName) ? findOrCreateGroup(groupName) : null; + final Role role = StringUtils.isNotBlank(roleName) ? findOrCreateRole(roleName) : null; + auth = new Authorization(group, role); + } else { + final Group group = StringUtils.isNotBlank(groupName) ? createTransientGroup(groupName) : null; + final Role role = StringUtils.isNotBlank(roleName) ? (createRoleIfMissing ? createTransientRole(roleName) : roleManager.getRole(roleName)) : null; + auth = new Authorization(group, role); + } + + if (elem.persist == PersistKind.FULL) { + persistAuthIfMissing(user, auth); + } + + user.addAuthorization(auth); + log.info("Successfully assigned {} to user {}", originalCandidate, user.getUsername()); } - private boolean isGroupRoleAlreadyAssigned(final KeycloakUser user, final String roleName, final String groupName) { + private boolean isAlreadyAssigned(final KeycloakUser user, final String roleName, final String groupName) { return user.getAuthorizations().stream() .anyMatch(a -> { final String existingRoleName = (a.getRole() != null) ? a.getRole().getName() : null; @@ -462,10 +431,6 @@ private boolean isGroupRoleAlreadyAssigned(final KeycloakUser user, final String }); } - private Authorization createTransientRoleAuthorization(String roleName) { - final Role role = createTransientRole(roleName); - return new Authorization(null, role); - } private @NonNull Role createTransientRole(String roleName) { Role role = roleManager.getRole(roleName); @@ -495,47 +460,14 @@ private void finalizeGroupAssociation(KeycloakUser user, DynamicMappingElement e for (String groupName : authorizations) { try { - if (isGroupAlreadyAssigned(user, groupName)) { - log.debug("Group {} already assigned to user {}", groupName, user.getUsername()); - continue; - } - - Authorization auth = (elem.persist == PersistKind.AUTH || elem.persist == PersistKind.FULL) - ? new Authorization(findOrCreateGroup(groupName), null) - : createTransientGroupAuthorization(groupName); - - // optionally persist - if (elem.persist == PersistKind.FULL) { - persistAuthIfMissing(user, auth); - } - - user.addAuthorization(auth); - log.info("Successfully assigned group {} to user {}", groupName, user.getUsername()); - + finalizeAssociation(user, elem, null, groupName, groupName); } catch (Exception e) { log.error("Error processing dynamic group '{}' for user {}", groupName, user.getUsername(), e); } } } - private boolean isGroupAlreadyAssigned(KeycloakUser user, String groupName) { - return user.getAuthorizations().stream() - .anyMatch(a -> a.getGroup() != null && groupName.equals(a.getGroup().getName())); - } - private Authorization createTransientGroupAuthorization(String groupName) { - Group group = new Group(); - group.setName(groupName); - group.setDescription(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 - */ /** * To avoid creating duplicate records, we check if the authorization already exists. @@ -577,11 +509,4 @@ private void persistAuthIfMissing(KeycloakUser user, Authorization auth) throws } } - /** - * 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 - */ - } diff --git a/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/oidc/OidcMappingService.java b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/oidc/OidcMappingService.java index 9010bba2a..0c05fa9d7 100644 --- a/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/oidc/OidcMappingService.java +++ b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/oidc/OidcMappingService.java @@ -71,6 +71,12 @@ public List extractAuthorizationsFromJwt(String token, boolean decode, D return Collections.emptyList(); } + /** + * 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 + */ public List extractAuthorizationsFromProfile(KeycloakUser user, DynamicMappingElement elem) { if (user.getUserRepresentation() == null || user.getUserRepresentation().getAttributes() == null @@ -84,6 +90,12 @@ public List extractAuthorizationsFromProfile(KeycloakUser user, DynamicM return handleKeycloakAttribute(kcProfileAttr); } + /** + * 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 List handleKeycloakAttribute(Object attribute) { if (attribute instanceof List) { List list = (List) attribute; From 71d4a0c362834768a5fc783bb6f090a2da44a5f5 Mon Sep 17 00:00:00 2001 From: "Matteo E. Minnai" Date: Fri, 13 Feb 2026 14:53:23 +0100 Subject: [PATCH 08/44] ESB-950 Introduced ignore list to avoid importing unwanted groups --- .../KeycloakAuthorizationManager.java | 12 ++++ .../KeycloakAuthorizationManagerTest.java | 56 +++++++++++++++++++ 2 files changed, 68 insertions(+) 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 28bfb956d..3b3763dba 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 @@ -395,7 +395,19 @@ private void finalizeAssociation(KeycloakUser user, DynamicMappingElement elem, finalizeAssociation(user, elem, roleName, groupName, originalCandidate, true); } + private boolean isIgnored(String name) { + if (ignore == null || StringUtils.isBlank(name)) { + return false; + } + return ignore.contains(name.trim()); + } + private void finalizeAssociation(KeycloakUser user, DynamicMappingElement elem, String roleName, String groupName, String originalCandidate, boolean createRoleIfMissing) throws EntException { + if (isIgnored(roleName) || isIgnored(groupName)) { + log.info("Role {} or Group {} is in the ignore list. Skipping assignment for user {}", roleName, groupName, user.getUsername()); + return; + } + if (isAlreadyAssigned(user, roleName, groupName)) { log.debug("Role {} and group {} already assigned to user {}", roleName, groupName, user.getUsername()); return; diff --git a/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java b/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java index 4bd3808df..3d4c3306d 100644 --- a/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java +++ b/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java @@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; @@ -567,6 +568,40 @@ void testDynamicConfigurationRoleGroupOnLoginFromJwtNoPersist() throws Exception }); } + @Test + void testDynamicConfigurationWithIgnoredRoles() throws Exception { + when(configuration.getDefaultAuthorizations()).thenReturn(null); + when(userDetails.getUsername()).thenReturn("testuser"); + when(configManager.getConfigItem(anyString())).thenReturn(XML_WITH_IGNORE); + + manager.init(); + + manager.processNewUser(userDetails, JWT, false); + + // JWT contains "generico", "offline_access", "uma_authorization", "default-roles-entando" + // "generico" is ignored, so we expect only 3 calls + verify(authorizationManager, times(3)).addUserAuthorization(eq("testuser"), any()); + verify(authorizationManager, never()).addUserAuthorization(eq("testuser"), argThat(auth -> + auth.getRole() != null && "generico".equals(auth.getRole().getName()))); + } + + @Test + void testDynamicConfigurationWithIgnoredGroups() throws Exception { + when(configuration.getDefaultAuthorizations()).thenReturn(null); + when(userDetails.getUsername()).thenReturn("testuser"); + when(configManager.getConfigItem(anyString())).thenReturn(XML_WITH_IGNORE_GROUP); + + manager.init(); + + manager.processNewUser(userDetails, JWT, false); + + // JWT contains groups "Gruppo-Microsoft-Importato", "altro-gruppo" + // "altro-gruppo" is ignored, so we expect only 1 call + verify(authorizationManager, times(1)).addUserAuthorization(eq("testuser"), any()); + verify(authorizationManager, never()).addUserAuthorization(eq("testuser"), argThat(auth -> + auth.getGroup() != null && "altro-gruppo".equals(auth.getGroup().getName()))); + } + private Authorization authorization(final String groupName, final String roleName) { final Group group = new Group(); group.setName(groupName); @@ -685,6 +720,27 @@ private Authorization authorization(final String groupName, final String roleNam + " " + ""; + private static final String XML_WITH_IGNORE_GROUP = "" + + " " + + " true" + + " groups" + + " GROUPCLAIM" + + " FULL" + + " " + + " altro-gruppo" + + ""; + + private static final String XML_WITH_IGNORE = "" + + " " + + " true" + + " realm_access.roles" + + " ROLECLAIM" + + " FULL" + + " " + + " generico" + + " another_ignored" + + ""; + private static final String XML_ROLE_CLAIM = "" + " " + " true" From 1c3e5719d8f945a336ff7947072f6b87a2f71857 Mon Sep 17 00:00:00 2001 From: "Matteo E. Minnai" Date: Thu, 19 Feb 2026 10:40:47 +0100 Subject: [PATCH 09/44] ESB-950 aggiornamento test-containers --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index f7fdeb2dd..5365d9115 100644 --- a/pom.xml +++ b/pom.xml @@ -130,7 +130,7 @@ 1.3 3.11.2 5.7.2 - 1.17.6 + 1.20.4 4.0.1 1.3.2 1.1.3 From 848df22af9642ea978e46d1f12b48521c6d720fd Mon Sep 17 00:00:00 2001 From: "Matteo E. Minnai" Date: Thu, 19 Feb 2026 11:21:59 +0100 Subject: [PATCH 10/44] ESB-950 Fix git workflow --- .github/workflows/build.yml | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 686e59b40..603ddbef6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -42,7 +42,10 @@ jobs: if [ -f ".github/configure" ]; then . .github/configure "build"; fi gh.job.outputVar SKIP_SCANS gh.job.outputVar SKIP_TESTS - + + - name: Fix Docker environment for Testcontainers + run: echo "DOCKER_API_VERSION=" >> $GITHUB_ENV + - name: Cache Maven packages uses: actions/cache@v4 with: @@ -74,7 +77,7 @@ jobs: contents: read pull-requests: write - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 env: SONAR_PROJECT_KEY: ${{ vars.SONAR_PROJECT_KEY }} @@ -109,6 +112,9 @@ jobs: id: configure run: if [ -f ".github/configure" ]; then . .github/configure "test-and-scan"; fi + - name: Fix Docker environment for Testcontainers + run: echo "DOCKER_API_VERSION=" >> $GITHUB_ENV + - name: Test and Scan run: .github/test-and-scan.sh @@ -129,7 +135,7 @@ jobs: contents: read pull-requests: write - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 needs: build steps: From 3f7839a7c4dd9f6ad8d945d0b7ef8e15bc3195ae Mon Sep 17 00:00:00 2001 From: "Matteo E. Minnai" Date: Thu, 19 Feb 2026 12:07:42 +0100 Subject: [PATCH 11/44] ESB-950 Fix DB restore --- .../services/mapping/DynamicMappingKind.java | 2 +- .../services/mapping/PersistKind.java | 2 +- .../port/clob/production/sysconfig_kc.xml | 27 ++++++++++++++----- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/DynamicMappingKind.java b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/DynamicMappingKind.java index 9416913fc..db5c328bc 100644 --- a/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/DynamicMappingKind.java +++ b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/DynamicMappingKind.java @@ -12,7 +12,7 @@ public enum DynamicMappingKind { GROUPROLE("grouprole", false), ROLECLAIM("roleclaim", true), GROUPCLAIM("groupclaim", true), - ROLEGROUPCLAIM("ROLEGROUPCLAIM", true); + ROLEGROUPCLAIM("rolegroupclaim", true); private final String kind; @Getter diff --git a/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/PersistKind.java b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/PersistKind.java index a0ab9f09a..71026a97e 100644 --- a/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/PersistKind.java +++ b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/PersistKind.java @@ -28,6 +28,6 @@ public static PersistKind fromValue(String value) { .filter(k -> k.kind.equalsIgnoreCase(value)) .findFirst() .orElseThrow(() -> - new IllegalArgumentException("Unknown DynamicMappingKind: " + value)); + new IllegalArgumentException("Unknown PersistKind: " + value)); } } diff --git a/keycloak-plugin/src/main/resources/liquibase/entando-keycloak-auth/port/clob/production/sysconfig_kc.xml b/keycloak-plugin/src/main/resources/liquibase/entando-keycloak-auth/port/clob/production/sysconfig_kc.xml index f631f4c25..46368f5f8 100644 --- a/keycloak-plugin/src/main/resources/liquibase/entando-keycloak-auth/port/clob/production/sysconfig_kc.xml +++ b/keycloak-plugin/src/main/resources/liquibase/entando-keycloak-auth/port/clob/production/sysconfig_kc.xml @@ -2,27 +2,42 @@ false - sim730 - CLIENTROLE - false + groups + GROUPCLAIM + none + + + true + realm_access.roles + ROLECLAIM + none + + + true + realm_access.roles + ROLEGROUPCLAIM + _SEP_ + none false AD_ROLE ROLE - false + none false AD_GROUP GROUP - false + none false AD_GROUPROLE GROUPROLE _r_ - false + none + ignore_keycloak_group + ignore_keycloak_role From e53fc7b8516bb3b29911b30c96fd75129f8c3813 Mon Sep 17 00:00:00 2001 From: "Matteo Emanuele M." Date: Thu, 19 Feb 2026 14:27:34 +0100 Subject: [PATCH 12/44] Update build.yml --- .github/workflows/build.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 603ddbef6..5ec517700 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -116,7 +116,9 @@ jobs: run: echo "DOCKER_API_VERSION=" >> $GITHUB_ENV - name: Test and Scan - run: .github/test-and-scan.sh + run: | + unset DOCKER_API_VERSION + .github/test-and-scan.sh - name: Save the test report if: failure() From 0e613548c05adcc56f66aee539d8bb2ba6f51aca Mon Sep 17 00:00:00 2001 From: "Matteo Emanuele M." Date: Thu, 19 Feb 2026 14:58:00 +0100 Subject: [PATCH 13/44] Update build.yml --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5ec517700..000a13a3d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -24,7 +24,7 @@ jobs: contents: write pull-requests: read - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 From 00377f464fe5c3e4cbe96c57375cc460b19b25f3 Mon Sep 17 00:00:00 2001 From: "Matteo E. Minnai" Date: Thu, 19 Feb 2026 15:37:59 +0100 Subject: [PATCH 14/44] ESB-950 Test updated --- .../utils/RedisSentinelTestExtension.java | 19 +++++++++++++++---- .../jpredis/utils/RedisTestExtension.java | 16 ++++++++++++---- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/redis-plugin/src/test/java/org/entando/entando/plugins/jpredis/utils/RedisSentinelTestExtension.java b/redis-plugin/src/test/java/org/entando/entando/plugins/jpredis/utils/RedisSentinelTestExtension.java index 53865e16a..f52d39a6e 100644 --- a/redis-plugin/src/test/java/org/entando/entando/plugins/jpredis/utils/RedisSentinelTestExtension.java +++ b/redis-plugin/src/test/java/org/entando/entando/plugins/jpredis/utils/RedisSentinelTestExtension.java @@ -16,6 +16,8 @@ import org.junit.jupiter.api.extension.ParameterResolver; import org.mockito.MockedStatic; import org.mockito.Mockito; +import org.opentest4j.TestAbortedException; +import org.testcontainers.DockerClientFactory; import org.testcontainers.containers.DockerComposeContainer; public class RedisSentinelTestExtension implements BeforeAllCallback, AfterAllCallback, ParameterResolver { @@ -28,17 +30,24 @@ public class RedisSentinelTestExtension implements BeforeAllCallback, AfterAllCa public static final String REDIS_SENTINEL_SERVICE = "redis-sentinel"; private static DockerComposeContainer composeContainer; + private static boolean composeStarted = false; private MockedStatic mockedRedisEnvironment; @Override public void beforeAll(ExtensionContext extensionContext) throws Exception { - if (composeContainer == null) { - composeContainer = new DockerComposeContainer(new File("docker-compose-sentinel.yaml")) + if (!DockerClientFactory.instance().isDockerAvailable()) { + throw new TestAbortedException("Docker is not available"); + } + if (!composeStarted) { + DockerComposeContainer container = + new DockerComposeContainer(new File("docker-compose-sentinel.yaml")) .withExposedService(REDIS_SERVICE, REDIS_PORT) .withExposedService(REDIS_SLAVE_SERVICE, REDIS_PORT) .withExposedService(REDIS_SENTINEL_SERVICE, REDIS_SENTINEL_PORT); - composeContainer.start(); + container.start(); + composeContainer = container; + composeStarted = true; } mockedRedisEnvironment = Mockito.mockStatic(RedisEnvironmentVariables.class); mockedRedisEnvironment.when(() -> RedisEnvironmentVariables.active()).thenReturn(true); @@ -54,7 +63,9 @@ public void beforeAll(ExtensionContext extensionContext) throws Exception { @Override public void afterAll(ExtensionContext extensionContext) throws Exception { - mockedRedisEnvironment.close(); + if (mockedRedisEnvironment != null) { + mockedRedisEnvironment.close(); + } } @Retention(RetentionPolicy.RUNTIME) diff --git a/redis-plugin/src/test/java/org/entando/entando/plugins/jpredis/utils/RedisTestExtension.java b/redis-plugin/src/test/java/org/entando/entando/plugins/jpredis/utils/RedisTestExtension.java index 7509fd4e0..2bf930641 100644 --- a/redis-plugin/src/test/java/org/entando/entando/plugins/jpredis/utils/RedisTestExtension.java +++ b/redis-plugin/src/test/java/org/entando/entando/plugins/jpredis/utils/RedisTestExtension.java @@ -9,6 +9,8 @@ import org.junit.jupiter.api.extension.ParameterResolver; import org.mockito.MockedStatic; import org.mockito.Mockito; +import org.opentest4j.TestAbortedException; +import org.testcontainers.DockerClientFactory; import org.testcontainers.containers.GenericContainer; public class RedisTestExtension implements BeforeAllCallback, AfterAllCallback, ParameterResolver { @@ -22,9 +24,13 @@ public class RedisTestExtension implements BeforeAllCallback, AfterAllCallback, @Override public void beforeAll(ExtensionContext extensionContext) throws Exception { - if (redisContainer == null) { - redisContainer = new GenericContainer(REDIS_IMAGE).withExposedPorts(REDIS_PORT); - redisContainer.start(); + if (!DockerClientFactory.instance().isDockerAvailable()) { + throw new TestAbortedException("Docker is not available"); + } + if (redisContainer == null || !redisContainer.isRunning()) { + GenericContainer container = new GenericContainer(REDIS_IMAGE).withExposedPorts(REDIS_PORT); + container.start(); + redisContainer = container; } mockedRedisEnvironment = Mockito.mockStatic(RedisEnvironmentVariables.class); mockedRedisEnvironment.when(() -> RedisEnvironmentVariables.active()).thenReturn(true); @@ -35,7 +41,9 @@ public void beforeAll(ExtensionContext extensionContext) throws Exception { @Override public void afterAll(ExtensionContext extensionContext) throws Exception { - mockedRedisEnvironment.close(); + if (mockedRedisEnvironment != null) { + mockedRedisEnvironment.close(); + } } @Override From 9fb64c0d3528298ce0b66cf3482aefb96bd14b8a Mon Sep 17 00:00:00 2001 From: "Matteo Emanuele M." Date: Fri, 20 Feb 2026 09:14:59 +0100 Subject: [PATCH 15/44] Update pom.xml --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 5365d9115..406ca3c02 100644 --- a/pom.xml +++ b/pom.xml @@ -130,7 +130,7 @@ 1.3 3.11.2 5.7.2 - 1.20.4 + 1.21.4 4.0.1 1.3.2 1.1.3 From c4b880426b90adfc42c145090b24ab691518903d Mon Sep 17 00:00:00 2001 From: "Matteo E. Minnai" Date: Fri, 20 Feb 2026 09:52:49 +0100 Subject: [PATCH 16/44] ESB-950 Test coverage --- .../services/oidc/OidcMappingServiceTest.java | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/oidc/OidcMappingServiceTest.java diff --git a/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/oidc/OidcMappingServiceTest.java b/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/oidc/OidcMappingServiceTest.java new file mode 100644 index 000000000..c3da5e1b6 --- /dev/null +++ b/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/oidc/OidcMappingServiceTest.java @@ -0,0 +1,81 @@ +package org.entando.entando.keycloak.services.oidc; + +import com.fasterxml.jackson.core.JsonProcessingException; +import java.util.Collections; +import java.util.List; +import org.entando.entando.keycloak.services.mapping.DynamicMappingElement; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +class OidcMappingServiceTest { + + private OidcMappingService oidcMappingService; + + @BeforeEach + void setUp() { + oidcMappingService = new OidcMappingService(); + } + + @Test + void shouldReturnEmptyListWhenJwtFormatIsInvalid() { + String invalidToken = "part1.part2"; // Only 2 parts instead of 3 + DynamicMappingElement claimMapper = new DynamicMappingElement(); + claimMapper.path = "roles"; + + List result = oidcMappingService.extractAuthorizationsFromJwt(invalidToken, true, claimMapper, "testUser"); + + Assertions.assertEquals(Collections.emptyList(), result); + } + + @Test + void shouldHandleIllegalArgumentExceptionDuringBase64Decode() { + // A token with 3 parts but the second part is not valid Base64 + String invalidBase64Token = "header.invalid_base64_!.signature"; + DynamicMappingElement claimMapper = new DynamicMappingElement(); + claimMapper.path = "roles"; + + List result = oidcMappingService.extractAuthorizationsFromJwt(invalidBase64Token, true, claimMapper, "testUser"); + + Assertions.assertEquals(Collections.emptyList(), result); + } + + @Test + void shouldHandleJsonProcessingException() { + // Providing an invalid JSON string with decode=false to trigger JsonProcessingException in mapper.readTree(json) + String invalidJson = "{ invalid json }"; + DynamicMappingElement claimMapper = new DynamicMappingElement(); + claimMapper.path = "roles"; + + List result = oidcMappingService.extractAuthorizationsFromJwt(invalidJson, false, claimMapper, "testUser"); + + Assertions.assertEquals(Collections.emptyList(), result); + } + + @Test + void shouldHandleGenericException() { + // Passing null for claimMapper should trigger a NullPointerException, which is caught by the generic Exception catch block + String token = "anyToken"; + + List result = oidcMappingService.extractAuthorizationsFromJwt(token, false, null, "testUser"); + + Assertions.assertEquals(Collections.emptyList(), result); + } + + @Test + void shouldExtractAuthorizationsSuccessfully() { + // Test case for successful extraction to ensure basic functionality is still working + // Payload: {"roles": ["admin", "user"]} -> Base64: eyJyb2xlcyI6IFsiYWRtaW4iLCAidXNlciJdfQ== + String payload = "eyJyb2xlcyI6IFsiYWRtaW4iLCAidXNlciJdfQ=="; + String token = "header." + payload + ".signature"; + DynamicMappingElement claimMapper = new DynamicMappingElement(); + claimMapper.path = "roles"; + + List result = oidcMappingService.extractAuthorizationsFromJwt(token, true, claimMapper, "testUser"); + + Assertions.assertEquals(2, result.size()); + Assertions.assertTrue(result.contains("admin")); + Assertions.assertTrue(result.contains("user")); + } +} From 0a98437b53b5377b12c9870a53ef0d2cf6e5c83d Mon Sep 17 00:00:00 2001 From: "Matteo E. Minnai" Date: Fri, 20 Feb 2026 10:54:27 +0100 Subject: [PATCH 17/44] ESB-950 Refactored ROLEGROUP mapper --- .../KeycloakAuthorizationManager.java | 18 ++--- .../mapping/DynamicMappingElement.java | 2 +- .../services/mapping/DynamicMappingKind.java | 2 +- .../port/clob/production/sysconfig_kc.xml | 6 +- .../KeycloakAuthorizationManagerTest.java | 73 +++++++++++++++---- 5 files changed, 74 insertions(+), 27 deletions(-) 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 3b3763dba..9c4aeae3a 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 @@ -2,7 +2,7 @@ import static java.util.Optional.ofNullable; 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.ROLEGROUP; import static org.entando.entando.keycloak.services.mapping.DynamicMappingKind.ROLE; import static org.entando.entando.keycloak.services.mapping.DynamicMappingKind.ROLEGROUPCLAIM; @@ -151,7 +151,7 @@ private boolean isValid(DynamicMappingElement elem) { log.error("invalid dynamic mapping element, 'path' is blank for {} kind", elem.kind); return false; } - if (StringUtils.isBlank(elem.separator) && (elem.kind == GROUPROLE || elem.kind == ROLEGROUPCLAIM)) { + if (StringUtils.isBlank(elem.separator) && (elem.kind == ROLEGROUP || elem.kind == ROLEGROUPCLAIM)) { log.error("invalid dynamic mapping element, 'separator' is blank for {} kind", elem.kind); return false; } @@ -321,13 +321,13 @@ private void processProfileAttributes(final KeycloakUser user) { if (m.kind == GROUP) { doProcessGroup(user, m); } - if (m.kind == GROUPROLE) { - doProcessGroupRole(user, m); + if (m.kind == ROLEGROUP) { + doProcessRoleGroup(user, m); } }); } - private void doProcessGroupRole(KeycloakUser user, DynamicMappingElement elem) { + private void doProcessRoleGroup(KeycloakUser user, DynamicMappingElement elem) { final String separator = StringUtils.isBlank(elem.separator) ? DEFAULT_SEPARATOR : elem.separator; @@ -338,14 +338,14 @@ private void doProcessGroupRole(KeycloakUser user, DynamicMappingElement elem) { return; } for (String groupRoleToken : authorizations) { - parseAuthForGroupRole(user, elem, groupRoleToken, separator); + parseAuthForRoleGroup(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) + private void parseAuthForRoleGroup(KeycloakUser user, DynamicMappingElement elem, String groupRoleToken, String separator) throws EntException { final String[] tokens = groupRoleToken.split(separator); @@ -356,8 +356,8 @@ private void parseAuthForGroupRole(KeycloakUser user, DynamicMappingElement elem return; } - final String groupName = tokens[0]; - final String roleName = tokens[1]; + final String groupName = tokens[1]; + final String roleName = tokens[0]; finalizeAssociation(user, elem, roleName, groupName, groupRoleToken, false); } diff --git a/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/DynamicMappingElement.java b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/DynamicMappingElement.java index a69fa83c0..e4890cb21 100644 --- a/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/DynamicMappingElement.java +++ b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/DynamicMappingElement.java @@ -7,7 +7,7 @@ public class DynamicMappingElement { public String attribute; public DynamicMappingKind kind; public PersistKind persist; - public String separator; // FOR GROUPROLE and GROUPROLECLAIM ONLY + public String separator; // FOR ROLEGROUP and GROUPROLECLAIM ONLY public String path; // FOR *CLAIM ONLY } diff --git a/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/DynamicMappingKind.java b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/DynamicMappingKind.java index db5c328bc..d682702b0 100644 --- a/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/DynamicMappingKind.java +++ b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/DynamicMappingKind.java @@ -9,7 +9,7 @@ public enum DynamicMappingKind { GROUP("group", false), ROLE("role", false), - GROUPROLE("grouprole", false), + ROLEGROUP("rolegroup", false), ROLECLAIM("roleclaim", true), GROUPCLAIM("groupclaim", true), ROLEGROUPCLAIM("rolegroupclaim", true); diff --git a/keycloak-plugin/src/main/resources/liquibase/entando-keycloak-auth/port/clob/production/sysconfig_kc.xml b/keycloak-plugin/src/main/resources/liquibase/entando-keycloak-auth/port/clob/production/sysconfig_kc.xml index 46368f5f8..5197ea8df 100644 --- a/keycloak-plugin/src/main/resources/liquibase/entando-keycloak-auth/port/clob/production/sysconfig_kc.xml +++ b/keycloak-plugin/src/main/resources/liquibase/entando-keycloak-auth/port/clob/production/sysconfig_kc.xml @@ -7,13 +7,13 @@ none - true + false realm_access.roles ROLECLAIM none - true + false realm_access.roles ROLEGROUPCLAIM _SEP_ @@ -34,7 +34,7 @@ false AD_GROUPROLE - GROUPROLE + ROLEGROUP _r_ none diff --git a/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java b/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java index 3d4c3306d..f5ff2370d 100644 --- a/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java +++ b/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java @@ -307,7 +307,7 @@ void testDynamicConfigurationGroupRoleOnLogin() throws Exception { when(configManager.getConfigItem(anyString())).thenReturn(XML_GROUP_ROLE_CONF); UserRepresentation userRepresentation = new UserRepresentation(); - userRepresentation.setAttributes(Map.of("AD_GROUPROLE", List.of("agroup_r_arole"))); + userRepresentation.setAttributes(Map.of("AD_GROUPROLE", List.of("arole_r_agroup"))); when(userDetails.getUsername()).thenReturn("testuser"); when(userDetails.getUserRepresentation()).thenReturn(userRepresentation); @@ -330,7 +330,7 @@ void testDynamicConfigurationGroupRoleOnLoginNoPersist() throws Exception { when(configManager.getConfigItem(anyString())).thenReturn(XML_GROUP_ROLE_CONF_NO_PERSIST); UserRepresentation userRepresentation = new UserRepresentation(); - userRepresentation.setAttributes(Map.of("AD_GROUPROLE", List.of("agroup_r_arole"))); + userRepresentation.setAttributes(Map.of("AD_GROUPROLE", List.of("arole_r_agroup"))); when(userDetails.getUserRepresentation()).thenReturn(userRepresentation); @@ -364,7 +364,7 @@ void testDynamicConfigurationGroupRoleOnLoginAlreadyPresent() throws Exception { when(configManager.getConfigItem(anyString())).thenReturn(XML_GROUP_ROLE_CONF); UserRepresentation userRepresentation = new UserRepresentation(); - userRepresentation.setAttributes(Map.of("AD_GROUPROLE", List.of("agroup_r_arole"))); + userRepresentation.setAttributes(Map.of("AD_GROUPROLE", List.of("arole_r_agroup"))); when(userDetails.getUsername()).thenReturn("testuser"); when(userDetails.getUserRepresentation()).thenReturn(userRepresentation); @@ -384,7 +384,7 @@ void testDynamicConfigurationNoGroupOnlyRoleOnLogin() throws Exception { when(configManager.getConfigItem(anyString())).thenReturn(XML_GROUP_ROLE_CONF); UserRepresentation userRepresentation = new UserRepresentation(); - userRepresentation.setAttributes(Map.of("AD_GROUPROLE", List.of("_r_arole"))); + userRepresentation.setAttributes(Map.of("AD_GROUPROLE", List.of("arole_r_"))); when(userDetails.getUserRepresentation()).thenReturn(userRepresentation); @@ -401,7 +401,7 @@ void testDynamicConfigurationOnlyGroupNoRoleOnLogin() throws Exception { when(configManager.getConfigItem(anyString())).thenReturn(XML_GROUP_ROLE_CONF); UserRepresentation userRepresentation = new UserRepresentation(); - userRepresentation.setAttributes(Map.of("AD_GROUPROLE", List.of("group_r_"))); + userRepresentation.setAttributes(Map.of("AD_GROUPROLE", List.of("_r_agroup"))); when(userDetails.getUserRepresentation()).thenReturn(userRepresentation); @@ -463,7 +463,7 @@ void testDynamicConfigurationGroupRoleOnLoginConflict() throws Exception { when(roleManager.getRole(anyString())).thenReturn(role); UserRepresentation userRepresentation = new UserRepresentation(); - userRepresentation.setAttributes(Map.of("AD_GROUPROLE", List.of("agroup_r_arole"))); + userRepresentation.setAttributes(Map.of("AD_GROUPROLE", List.of("arole_r_agroup"))); when(userDetails.getUsername()).thenReturn("testuser"); when(userDetails.getUserRepresentation()).thenReturn(userRepresentation); @@ -602,6 +602,53 @@ void testDynamicConfigurationWithIgnoredGroups() throws Exception { auth.getGroup() != null && "altro-gruppo".equals(auth.getGroup().getName()))); } + @Test + void testRoleFromProfileAndJwtWithPersistAuth() throws Exception { + // Configurazione: un mapping per profilo (ROLE) e uno per JWT (ROLECLAIM), entrambi con persist=AUTH + String xmlConf = "" + + " " + + " true" + + " AD_ROLE" + + " ROLE" + + " AUTH" + + " " + + " " + + " true" + + " realm_access.roles" + + " ROLECLAIM" + + " AUTH" + + " " + + ""; + + when(configuration.getDefaultAuthorizations()).thenReturn(null); + when(configManager.getConfigItem(anyString())).thenReturn(xmlConf); + + // Ruolo dal profilo + UserRepresentation userRepresentation = new UserRepresentation(); + userRepresentation.setAttributes(Map.of("AD_ROLE", List.of("role_from_profile"))); + when(userDetails.getUserRepresentation()).thenReturn(userRepresentation); + when(userDetails.getUsername()).thenReturn("testuser"); + when(userDetails.getAuthorizations()).thenReturn(new ArrayList<>()); + + // Il JWT (costante JWT definita nella classe) contiene "generico" tra i ruoli in realm_access.roles + // JWT_NO_ROLE non ha "generico" ma ha "offline_access", "uma_authorization", "default-roles-entando" + + manager.init(); + manager.processNewUser(userDetails, JWT, false); + + // Verifichiamo che addUserAuthorization NON sia mai chiamato (perché persist è AUTH, non FULL) + verify(authorizationManager, never()).addUserAuthorization(anyString(), any()); + + // Verifichiamo che le autorizzazioni siano state aggiunte all'oggetto userDetails + ArgumentCaptor authCaptor = ArgumentCaptor.forClass(Authorization.class); + // "generico" e "role_from_profile" (più altri eventuali dal JWT standard se non filtrati) + verify(userDetails, org.mockito.Mockito.atLeastOnce()).addAuthorization(authCaptor.capture()); + + List captured = authCaptor.getAllValues(); + assertThat(captured).anySatisfy(a -> assertThat(a.getRole().getName()).isEqualTo("role_from_profile")); + assertThat(captured).anySatisfy(a -> assertThat(a.getRole().getName()).isEqualTo("generico")); + } + private Authorization authorization(final String groupName, final String roleName) { final Group group = new Group(); group.setName(groupName); @@ -626,7 +673,7 @@ private Authorization authorization(final String groupName, final String roleNam + " " + " false" + " AD_GROUPROLE" - + " GROUPROLE" + + " ROLEGROUP" + " _r_" + " FULL" + " " @@ -648,7 +695,7 @@ private Authorization authorization(final String groupName, final String roleNam + " " + " false" + " AD_GROUPROLE" - + " GROUPROLE" + + " ROLEGROUP" + " _r_" + " FULL" + " " @@ -670,7 +717,7 @@ private Authorization authorization(final String groupName, final String roleNam + " " + " false" + " AD_GROUPROLE" - + " GROUPROLE" + + " ROLEGROUP" + " _r_" + " FULL" + " " @@ -692,7 +739,7 @@ private Authorization authorization(final String groupName, final String roleNam + " " + " true" + " AD_GROUPROLE" - + " GROUPROLE" + + " ROLEGROUP" + " _r_" + " FULL" + " " @@ -714,7 +761,7 @@ private Authorization authorization(final String groupName, final String roleNam + " " + " true" + " AD_GROUPROLE" - + " GROUPROLE" + + " ROLEGROUP" + " _r_" + " NONE" + " " @@ -819,7 +866,7 @@ private Authorization authorization(final String groupName, final String roleNam + " " + " false" // + " AD_GROUPROLE" // attribute null - + " GROUPROLE" + + " ROLEGROUP" + " _r_" + " FULL" + " " @@ -834,7 +881,7 @@ private Authorization authorization(final String groupName, final String roleNam + " " + " false" + " AD_GROUPROLE" - + " GROUPROLE" + + " ROLEGROUP" // + " _r_" // separator null + " FULL" + " " From 9701c6aa009417f78cab5075bfc2191b58fbddbf Mon Sep 17 00:00:00 2001 From: "Matteo E. Minnai" Date: Fri, 20 Feb 2026 18:59:39 +0100 Subject: [PATCH 18/44] ESB-950 Improved test --- .../KeycloakAuthenticationFilter.java | 1 - .../KeycloakAuthorizationManagerTest.java | 77 +++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) 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 4b1a7f4c3..0a44b20d3 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,7 +87,6 @@ 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(); diff --git a/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java b/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java index f5ff2370d..c51aa80e3 100644 --- a/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java +++ b/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java @@ -649,6 +649,83 @@ void testRoleFromProfileAndJwtWithPersistAuth() throws Exception { assertThat(captured).anySatisfy(a -> assertThat(a.getRole().getName()).isEqualTo("generico")); } + @Test + void testAuthAssignmentWhenRoleGroupExistWithPersistAuth() throws Exception { + String xmlConf = "" + + " " + + " true" + + " AD_ROLE" + + " ROLE" + + " AUTH" + + " " + + ""; + + when(configuration.getDefaultAuthorizations()).thenReturn(null); + when(configManager.getConfigItem(anyString())).thenReturn(xmlConf); + + Role existingRole = new Role(); + existingRole.setName("existing_role"); + when(roleManager.getRole("existing_role")).thenReturn(existingRole); + + UserRepresentation userRepresentation = new UserRepresentation(); + userRepresentation.setAttributes(Map.of("AD_ROLE", List.of("existing_role"))); + when(userDetails.getUserRepresentation()).thenReturn(userRepresentation); + when(userDetails.getUsername()).thenReturn("testuser"); + when(userDetails.getAuthorizations()).thenReturn(new ArrayList<>()); + + manager.init(); + manager.processNewUser(userDetails, null, false); + + // Verifichiamo che l'autorizzazione sia stata aggiunta all'utente + ArgumentCaptor authCaptor = ArgumentCaptor.forClass(Authorization.class); + verify(userDetails, times(1)).addAuthorization(authCaptor.capture()); + assertThat(authCaptor.getValue().getRole().getName()).isEqualTo("existing_role"); + + // Verifichiamo che non sia stata chiamata la persistenza + verify(authorizationManager, never()).addUserAuthorization(anyString(), any()); + } + + @Test + void testAuthAssignmentWhenRoleExistsAndAddRoleFailsWithPersistAuth() throws Exception { + String xmlConf = "" + + " " + + " true" + + " AD_ROLE" + + " ROLE" + + " AUTH" + + " " + + ""; + + when(configuration.getDefaultAuthorizations()).thenReturn(null); + when(configManager.getConfigItem(anyString())).thenReturn(xmlConf); + + Role existingRole = new Role(); + existingRole.setName("conflict_role"); + + // Prima ritorna null (simulando che non lo trova), poi dopo l'errore di addRole lo trova + when(roleManager.getRole("conflict_role")) + .thenReturn(null) + .thenReturn(existingRole); + + // Simula conflitto su addRole + org.mockito.Mockito.doThrow(new EntException("Conflict")) + .when(roleManager).addRole(any(Role.class)); + + UserRepresentation userRepresentation = new UserRepresentation(); + userRepresentation.setAttributes(Map.of("AD_ROLE", List.of("conflict_role"))); + when(userDetails.getUserRepresentation()).thenReturn(userRepresentation); + when(userDetails.getUsername()).thenReturn("testuser"); + when(userDetails.getAuthorizations()).thenReturn(new ArrayList<>()); + + manager.init(); + manager.processNewUser(userDetails, null, false); + + // Verifichiamo che l'autorizzazione sia stata comunque aggiunta all'utente + ArgumentCaptor authCaptor = ArgumentCaptor.forClass(Authorization.class); + verify(userDetails, times(1)).addAuthorization(authCaptor.capture()); + assertThat(authCaptor.getValue().getRole().getName()).isEqualTo("conflict_role"); + } + private Authorization authorization(final String groupName, final String roleName) { final Group group = new Group(); group.setName(groupName); From b58de4f70f40e4696175c18fdd643db9f161728f Mon Sep 17 00:00:00 2001 From: "Matteo E. Minnai" Date: Mon, 23 Feb 2026 11:14:37 +0100 Subject: [PATCH 19/44] ESB-950 Quality gate --- .../KeycloakAuthorizationManager.java | 43 ++++++++--- .../services/oidc/OidcMappingService.java | 76 +++++++++++-------- 2 files changed, 78 insertions(+), 41 deletions(-) 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 9c4aeae3a..0408f74dd 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 @@ -50,7 +50,6 @@ public class KeycloakAuthorizationManager extends AbstractService { private final GroupManager groupManager; private final RoleManager roleManager; private final BaseConfigManager configManager; - private final OidcMappingService oidcMappingService; private static final int GROUP_POSITION = 0; private static final int ROLE_POSITION = 1; @@ -60,6 +59,7 @@ public class KeycloakAuthorizationManager extends AbstractService { private final transient ReadWriteLock configUpdateLock = new ReentrantReadWriteLock(); private final transient Lock readLock = configUpdateLock.readLock(); private final transient Lock writeLock = configUpdateLock.writeLock(); + private final transient OidcMappingService oidcMappingService; @Autowired public KeycloakAuthorizationManager(final KeycloakConfiguration configuration, @@ -413,16 +413,7 @@ private void finalizeAssociation(KeycloakUser user, DynamicMappingElement elem, return; } - Authorization auth; - if (elem.persist == PersistKind.AUTH || elem.persist == PersistKind.FULL) { - final Group group = StringUtils.isNotBlank(groupName) ? findOrCreateGroup(groupName) : null; - final Role role = StringUtils.isNotBlank(roleName) ? findOrCreateRole(roleName) : null; - auth = new Authorization(group, role); - } else { - final Group group = StringUtils.isNotBlank(groupName) ? createTransientGroup(groupName) : null; - final Role role = StringUtils.isNotBlank(roleName) ? (createRoleIfMissing ? createTransientRole(roleName) : roleManager.getRole(roleName)) : null; - auth = new Authorization(group, role); - } + Authorization auth = createAuthorization(elem, roleName, groupName, createRoleIfMissing); if (elem.persist == PersistKind.FULL) { persistAuthIfMissing(user, auth); @@ -432,6 +423,36 @@ private void finalizeAssociation(KeycloakUser user, DynamicMappingElement elem, log.info("Successfully assigned {} to user {}", originalCandidate, user.getUsername()); } + private Authorization createAuthorization(DynamicMappingElement elem, String roleName, String groupName, boolean createRoleIfMissing) { + if (shouldPersistAuthorization(elem)) { + return createPersistedAuthorization(roleName, groupName); + } + return createTransientAuthorization(roleName, groupName, createRoleIfMissing); + } + + private boolean shouldPersistAuthorization(DynamicMappingElement elem) { + return elem.persist == PersistKind.AUTH || elem.persist == PersistKind.FULL; + } + + private Authorization createPersistedAuthorization(String roleName, String groupName) { + final Group group = StringUtils.isNotBlank(groupName) ? findOrCreateGroup(groupName) : null; + final Role role = StringUtils.isNotBlank(roleName) ? findOrCreateRole(roleName) : null; + return new Authorization(group, role); + } + + private Authorization createTransientAuthorization(String roleName, String groupName, boolean createRoleIfMissing) { + final Group group = StringUtils.isNotBlank(groupName) ? createTransientGroup(groupName) : null; + final Role role = resolveTransientRole(roleName, createRoleIfMissing); + return new Authorization(group, role); + } + + private Role resolveTransientRole(String roleName, boolean createRoleIfMissing) { + if (StringUtils.isBlank(roleName)) { + return null; + } + return createRoleIfMissing ? createTransientRole(roleName) : roleManager.getRole(roleName); + } + private boolean isAlreadyAssigned(final KeycloakUser user, final String roleName, final String groupName) { return user.getAuthorizations().stream() .anyMatch(a -> { diff --git a/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/oidc/OidcMappingService.java b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/oidc/OidcMappingService.java index 0c05fa9d7..bc8bd2044 100644 --- a/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/oidc/OidcMappingService.java +++ b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/oidc/OidcMappingService.java @@ -24,41 +24,15 @@ public class OidcMappingService { public List extractAuthorizationsFromJwt(String token, boolean decode, DynamicMappingElement claimMapper, String username) { try { - String json; - if (decode) { - String[] parts = token.split("\\."); - if (parts.length != 3) { - log.error("Invalid JWT token format: expected 3 parts, found {}", parts.length); - return Collections.emptyList(); - } - json = new String(Base64.getUrlDecoder().decode(parts[1])); - } else { - json = token; - } - - final JsonNode root = mapper.readTree(json); - final String jwtPath = "/" + claimMapper.path.replace(".", "/"); - JsonNode authNode = root.at(jwtPath); + String json = decodeTokenIfNeeded(token, decode); + JsonNode authNode = extractAuthNodeFromJson(json, claimMapper); - if (authNode == null || authNode.isMissingNode() || authNode.isNull()) { + if (isNodeMissing(authNode)) { log.debug("Path '{}' not found in JWT claims for user {}", claimMapper.path, username); return Collections.emptyList(); } - List authorizations = new ArrayList<>(); - if (authNode.isArray()) { - for (JsonNode node : authNode) { - if (node.isTextual()) { - authorizations.add(node.asText()); - } - } - } else if (authNode.isTextual()) { - authorizations.add(authNode.asText()); - } else { - log.warn("Unsupported node type for path '{}' in JWT: {}", claimMapper.path, authNode.getNodeType()); - return Collections.emptyList(); - } - return authorizations; + return extractAuthorizationsFromNode(authNode, claimMapper); } catch (IllegalArgumentException e) { log.error("Error decoding JWT payload for user {}", username, e); @@ -71,6 +45,48 @@ public List extractAuthorizationsFromJwt(String token, boolean decode, D return Collections.emptyList(); } + private String decodeTokenIfNeeded(String token, boolean decode) { + if (!decode) { + return token; + } + String[] parts = token.split("\\."); + if (parts.length != 3) { + throw new IllegalArgumentException("Invalid JWT token format: expected 3 parts, found " + parts.length); + } + return new String(Base64.getUrlDecoder().decode(parts[1])); + } + + private JsonNode extractAuthNodeFromJson(String json, DynamicMappingElement claimMapper) throws JsonProcessingException { + final JsonNode root = mapper.readTree(json); + final String jwtPath = "/" + claimMapper.path.replace(".", "/"); + return root.at(jwtPath); + } + + private boolean isNodeMissing(JsonNode node) { + return node == null || node.isMissingNode() || node.isNull(); + } + + private List extractAuthorizationsFromNode(JsonNode authNode, DynamicMappingElement claimMapper) { + if (authNode.isArray()) { + return extractFromArrayNode(authNode); + } + if (authNode.isTextual()) { + return List.of(authNode.asText()); + } + log.warn("Unsupported node type for path '{}' in JWT: {}", claimMapper.path, authNode.getNodeType()); + return Collections.emptyList(); + } + + private List extractFromArrayNode(JsonNode arrayNode) { + List authorizations = new ArrayList<>(); + for (JsonNode node : arrayNode) { + if (node.isTextual()) { + authorizations.add(node.asText()); + } + } + return authorizations; + } + /** * Process dynamic configuration element for a user. If the attribute is missing, it skips processing. * @param user the Keycloak user From 3d4651184116569d7e37bf4db99962b433c6f6f2 Mon Sep 17 00:00:00 2001 From: "Matteo E. Minnai" Date: Mon, 23 Feb 2026 15:42:24 +0100 Subject: [PATCH 20/44] ESB-950 Fix bug that prevented the authorization being updated --- .../KeycloakAuthorizationManager.java | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) 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 0408f74dd..7b1631da2 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 @@ -281,7 +281,7 @@ private Group findOrCreateGroup(String groupName) { try { groupManager.addGroup(newGroup); return newGroup; - } catch (EntException e) { + } catch (Exception e) { log.debug("Error persisting group {} ( It might have been already added by another process).", groupName); return groupManager.getGroup(groupName); @@ -289,19 +289,19 @@ private Group findOrCreateGroup(String groupName) { } private Role findOrCreateRole(final String roleName) { - Role role = roleManager.getRole(roleName); + Role newRole = roleManager.getRole(roleName); - if (role != null) { - return role; + if (newRole != null) { + return newRole; } - role = new Role(); - role.setName(roleName); - role.setDescription(roleName); + newRole = new Role(); + newRole.setName(roleName); + newRole.setDescription(roleName); try { - roleManager.addRole(role); - return role; - } catch (EntException e) { + roleManager.addRole(newRole); + return newRole; + } catch (Exception e) { log.debug("Error persisting role {} (It might have been already added by another process).", roleName); return roleManager.getRole(roleName); From 409229b9a51485483b9d22dbfc7b5110d4e6cadc Mon Sep 17 00:00:00 2001 From: "Matteo E. Minnai" Date: Tue, 24 Feb 2026 06:16:42 +0100 Subject: [PATCH 21/44] ESB-950 Remove managed authorizations from the user at login when none are explicitly assigned --- .../KeycloakAuthorizationManager.java | 50 +++++++++++++ .../services/mapping/DynamicMapping.java | 6 ++ .../KeycloakAuthorizationManagerTest.java | 72 +++++++++++++++++++ 3 files changed, 128 insertions(+) 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 7b1631da2..904d72c84 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 @@ -84,6 +84,8 @@ public KeycloakAuthorizationManager(final KeycloakConfiguration configuration, private transient List profileMappings; private transient List jwtMappings; private transient List ignore; + private transient List roles; + private transient List groups; @Override public void init() throws Exception { @@ -109,6 +111,8 @@ public void init() throws Exception { dynConf.mapping.size(), profileMappings.size()); } ignore = dynConf.ignore; + roles = dynConf.roles; + groups = dynConf.groups; } } if (profileMappings != null) { @@ -174,11 +178,57 @@ public void processNewUser(final UserDetails user, final String token, final boo && !profileMappings.isEmpty()) { processProfileAttributes((KeycloakUser) user); } + + this.cleanupManagedAuthorizations(user); } finally { readLock.unlock(); } } + private void cleanupManagedAuthorizations(UserDetails user) { + if ((roles == null || roles.isEmpty()) && (groups == null || groups.isEmpty())) { + return; + } + + final List userAuths = user.getAuthorizations(); + final Set assignedRoles = userAuths.stream() + .map(Authorization::getRole) + .filter(Objects::nonNull) + .map(Role::getName) + .collect(Collectors.toSet()); + final Set assignedGroups = userAuths.stream() + .map(Authorization::getGroup) + .filter(Objects::nonNull) + .map(Group::getName) + .collect(Collectors.toSet()); + + if (roles != null) { + for (String managedRole : roles) { + if (!assignedRoles.contains(managedRole)) { + log.debug("Removing managed role {} from user {}", managedRole, user.getUsername()); + try { + authorizationManager.deleteUserAuthorization(user.getUsername(), null, managedRole); + } catch (Exception e) { + log.error("Error removing managed role {} for user {}", managedRole, user.getUsername(), e); + } + } + } + } + + if (groups != null) { + for (String managedGroup : groups) { + if (!assignedGroups.contains(managedGroup)) { + log.debug("Removing managed group {} from user {}", managedGroup, user.getUsername()); + try { + authorizationManager.deleteUserAuthorization(user.getUsername(), managedGroup, null); + } catch (Exception e) { + log.error("Error removing managed group {} for user {}", managedGroup, user.getUsername(), e); + } + } + } + } + } + /** * Analyze the JWT looking for known mappings to translate into Entando roles * @param user logged in user diff --git a/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/DynamicMapping.java b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/DynamicMapping.java index 923121d71..ea6a94ff7 100644 --- a/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/DynamicMapping.java +++ b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/DynamicMapping.java @@ -15,4 +15,10 @@ public class DynamicMapping { @JacksonXmlElementWrapper(useWrapping = false) public List ignore; + @JacksonXmlElementWrapper(localName = "roles") + public List roles; + + @JacksonXmlElementWrapper(localName = "groups") + public List groups; + } diff --git a/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java b/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java index c51aa80e3..b734f7236 100644 --- a/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java +++ b/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java @@ -726,6 +726,78 @@ void testAuthAssignmentWhenRoleExistsAndAddRoleFailsWithPersistAuth() throws Exc assertThat(authCaptor.getValue().getRole().getName()).isEqualTo("conflict_role"); } + @Test + void testCleanupManagedAuthorizations() throws Exception { + String xml = "" + + " " + + " managed-role-1" + + " managed-role-2" + + " " + + " " + + " managed-group-1" + + " managed-group-2" + + " " + + ""; + + when(configManager.getConfigItem("dynamicAuthMapping")).thenReturn(xml); + manager.init(); + + List userAuths = new ArrayList<>(); + // L'utente ha managed-role-1 e un ruolo NON gestito + userAuths.add(authorization(null, "managed-role-1")); + userAuths.add(authorization(null, "other-role")); + // L'utente ha managed-group-1 e un gruppo NON gestito + userAuths.add(authorization("managed-group-1", null)); + userAuths.add(authorization("other-group", null)); + + when(userDetails.getAuthorizations()).thenReturn(userAuths); + when(userDetails.getUsername()).thenReturn("test-user"); + + manager.processNewUser(userDetails, null, false); + + // managed-role-2 deve essere rimosso perché gestito ma non presente + verify(authorizationManager).deleteUserAuthorization("test-user", null, "managed-role-2"); + // managed-group-2 deve essere rimosso perché gestito ma non presente + verify(authorizationManager).deleteUserAuthorization("test-user", "managed-group-2", null); + + // managed-role-1 NON deve essere rimosso + verify(authorizationManager, never()).deleteUserAuthorization("test-user", null, "managed-role-1"); + // managed-group-1 NON deve essere rimosso + verify(authorizationManager, never()).deleteUserAuthorization("test-user", "managed-group-1", null); + // Ruoli e gruppi non gestiti non devono essere rimossi + verify(authorizationManager, never()).deleteUserAuthorization("test-user", null, "other-role"); + verify(authorizationManager, never()).deleteUserAuthorization("test-user", "other-group", null); + } + + @Test + void testCleanupManagedAuthorizationsEmptyLists() throws Exception { + String xml = "" + + ""; + + when(configManager.getConfigItem("dynamicAuthMapping")).thenReturn(xml); + manager.init(); + + manager.processNewUser(userDetails, null, false); + + verify(authorizationManager, never()).deleteUserAuthorization(anyString(), anyString(), anyString()); + } + + @Test + void testCleanupManagedAuthorizationsNullLists() throws Exception { + // configManager.getConfigItem returns null or empty + when(configManager.getConfigItem("dynamicAuthMapping")).thenReturn(""); + manager.init(); + + List userAuths = new ArrayList<>(); + userAuths.add(authorization(null, "some-role")); + // No need to mock userDetails.getAuthorizations() if roles/groups are null/empty, + // but it doesn't hurt. + + manager.processNewUser(userDetails, null, false); + + verify(authorizationManager, never()).deleteUserAuthorization(anyString(), anyString(), anyString()); + } + private Authorization authorization(final String groupName, final String roleName) { final Group group = new Group(); group.setName(groupName); From ac8101e3eb1a3d7b94c4c8dee112d727d6d6a6a5 Mon Sep 17 00:00:00 2001 From: "Matteo E. Minnai" Date: Tue, 24 Feb 2026 06:20:16 +0100 Subject: [PATCH 22/44] ESB-950 Reverted pom.xml --- webapp/pom.xml | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/webapp/pom.xml b/webapp/pom.xml index debf08294..a80615bd2 100644 --- a/webapp/pom.xml +++ b/webapp/pom.xml @@ -48,16 +48,11 @@ 2.5.0 - true + false http://localhost:8081/auth - entando-development - entando-core - 930837f0-95b2-4eeb-b303-82a56cac76e6 - - - - - + entando + entando-app + b4b34472-9926-4753-9db8-a50f152df3da entando-web From a28d3961bb55e2059f42cc8ebd3937628cbc7e10 Mon Sep 17 00:00:00 2001 From: "Matteo E. Minnai" Date: Tue, 24 Feb 2026 16:45:03 +0100 Subject: [PATCH 23/44] ESB-950 Fix managed test management --- .../authorization/AuthorizationDAO.java | 90 +++++++++++++++ .../authorization/AuthorizationManager.java | 21 ++++ .../authorization/IAuthorizationDAO.java | 8 +- .../authorization/IAuthorizationManager.java | 7 +- .../AuthorizationManagerTest.java | 78 +++++++++++++ .../authorization/TestAuthorizationDAO.java | 64 +++++++++++ .../KeycloakAuthenticationFilter.java | 1 + .../keycloak/filter/KeycloakFilter.java | 2 + .../KeycloakAuthorizationManager.java | 49 +-------- .../KeycloakAuthorizationManagerTest.java | 104 +++++++++++------- webapp/pom.xml | 8 +- 11 files changed, 340 insertions(+), 92 deletions(-) create mode 100644 engine/src/test/java/com/agiletec/aps/system/services/authorization/AuthorizationManagerTest.java create mode 100644 engine/src/test/java/com/agiletec/aps/system/services/authorization/TestAuthorizationDAO.java diff --git a/engine/src/main/java/com/agiletec/aps/system/services/authorization/AuthorizationDAO.java b/engine/src/main/java/com/agiletec/aps/system/services/authorization/AuthorizationDAO.java index 8ad9c96b5..96d45e483 100644 --- a/engine/src/main/java/com/agiletec/aps/system/services/authorization/AuthorizationDAO.java +++ b/engine/src/main/java/com/agiletec/aps/system/services/authorization/AuthorizationDAO.java @@ -26,6 +26,7 @@ import java.util.List; import java.util.Map; +import java.util.StringJoiner; import org.apache.commons.collections.CollectionUtils; import org.entando.entando.ent.util.EntLogging.EntLogger; import org.entando.entando.ent.util.EntLogging.EntLogFactory; @@ -157,6 +158,95 @@ public List getUsersByAuthorities(List groupNames, List } return super.searchId(filters); } + + @Override + public int deleteUserGroups(String utente, List groups) { + + Connection conn = null; + PreparedStatement stat = null; + + if (groups == null || groups.isEmpty()) { + return 0; + } + + try { + conn = this.getConnection(); + conn.setAutoCommit(false); + + final StringJoiner placeholders = new StringJoiner(", "); + + for (int i = 0; i < groups.size(); i++) { + placeholders.add("?"); + } + + final String sql = DELETE_USER_AUTHORIZATIONS + " AND groupname IN (" + + placeholders + ")"; + + stat = conn.prepareStatement(sql); + + int index = 1; + stat.setString(index++, utente); + + for (String role : groups) { + stat.setString(index++, role); + } + + int rowsDeleted = stat.executeUpdate(); + + conn.commit(); + return rowsDeleted; + + } catch (Exception e) { + this.executeRollback(conn); + throw new RuntimeException("Error detected while deleting user groups authorizations", e); + } finally { + this.closeDaoResources(null, stat, conn); + } + } + + @Override + public int deleteUserRoles(String utente, List roles) { + Connection conn = null; + PreparedStatement stat = null; + + if (roles == null || roles.isEmpty()) { + return 0; + } + + try { + // Inizio transazione + conn = this.getConnection(); + conn.setAutoCommit(false); + + final StringJoiner placeholders = new StringJoiner(", "); + + for (int i = 0; i < roles.size(); i++) { + placeholders.add("?"); + } + + final String sql = DELETE_USER_AUTHORIZATIONS + " AND rolename IN (" + + placeholders + ")"; + + stat = conn.prepareStatement(sql); + + int index = 1; + stat.setString(index++, utente); + + for (String role : roles) { + stat.setString(index++, role); + } + + int rowsDeleted = stat.executeUpdate(); + + conn.commit(); + return rowsDeleted; + } catch (Exception e) { + this.executeRollback(conn); + throw new RuntimeException("Error detected while deleting user role authorizations", e); + } finally { + this.closeDaoResources(null, stat, conn); + } + } @Override protected String getTableFieldName(String metadataFieldKey) { 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 cf868a58b..1caaed7e1 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 @@ -637,6 +637,26 @@ public void deleteUser(Object key) { } } + @Override + public void deleteUserRoles(String username, List roles) throws EntException { + try { + this.getAuthorizationDAO().deleteUserRoles(username, roles); + } catch (Throwable t) { + _logger.error("Error deleting user roles for user '{}'", username, t); + throw new EntException("Error deleting user roles for user " + username, t); + } + } + + @Override + public void deleteUserGroups(String username, List groups) throws EntException { + try { + this.getAuthorizationDAO().deleteUserGroups(username, groups); + } catch (Throwable t) { + _logger.error("Error deleting user groups for user '{}'", username, t); + throw new EntException("Error deleting user groups for user " + username, t); + } + } + @Override public List getUsersByRole(IApsAuthority authority, boolean includeAdmin) throws EntException { if (null == authority || !(authority instanceof Role) || null == this.getRoleManager().getRole(authority.getAuthority())) { @@ -721,6 +741,7 @@ public List getGroupUtilizers(String groupName) throws EntException { return this.getUsersByGroup(groupName, false); } + protected IAuthorizationDAO getAuthorizationDAO() { return _authorizationDAO; } diff --git a/engine/src/main/java/com/agiletec/aps/system/services/authorization/IAuthorizationDAO.java b/engine/src/main/java/com/agiletec/aps/system/services/authorization/IAuthorizationDAO.java index 979711c6d..bb12e7591 100644 --- a/engine/src/main/java/com/agiletec/aps/system/services/authorization/IAuthorizationDAO.java +++ b/engine/src/main/java/com/agiletec/aps/system/services/authorization/IAuthorizationDAO.java @@ -16,6 +16,8 @@ import com.agiletec.aps.system.services.group.Group; import com.agiletec.aps.system.services.role.Role; +import java.sql.Connection; +import java.sql.SQLException; import java.util.List; import java.util.Map; @@ -37,4 +39,8 @@ public interface IAuthorizationDAO { public void deleteUserAuthorizations(String username); public List getUsersByAuthorities(List groupNames, List roleNames); -} \ No newline at end of file + + int deleteUserGroups(String utente, List groups); + + int deleteUserRoles(String utente, List roles); +} diff --git a/engine/src/main/java/com/agiletec/aps/system/services/authorization/IAuthorizationManager.java b/engine/src/main/java/com/agiletec/aps/system/services/authorization/IAuthorizationManager.java index 6ade34197..174a36641 100644 --- a/engine/src/main/java/com/agiletec/aps/system/services/authorization/IAuthorizationManager.java +++ b/engine/src/main/java/com/agiletec/aps/system/services/authorization/IAuthorizationManager.java @@ -185,5 +185,8 @@ public interface IAuthorizationManager { public List getUsersByGroup(IApsAuthority authority, boolean includeAdmin) throws EntException; public List getUsersByGroup(String groupName, boolean includeAdmin) throws EntException; - -} \ No newline at end of file + + void deleteUserRoles(String username, List roles) throws EntException; + + void deleteUserGroups(String username, List groups) throws EntException; +} diff --git a/engine/src/test/java/com/agiletec/aps/system/services/authorization/AuthorizationManagerTest.java b/engine/src/test/java/com/agiletec/aps/system/services/authorization/AuthorizationManagerTest.java new file mode 100644 index 000000000..cea228c04 --- /dev/null +++ b/engine/src/test/java/com/agiletec/aps/system/services/authorization/AuthorizationManagerTest.java @@ -0,0 +1,78 @@ +package com.agiletec.aps.system.services.authorization; + +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Arrays; +import java.util.List; +import org.entando.entando.ent.exception.EntException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class AuthorizationManagerTest { + + @Mock + private IAuthorizationDAO authorizationDAO; + + @InjectMocks + private AuthorizationManager authorizationManager; + + @BeforeEach + void setUp() { + } + + @Test + void shouldDeleteUserRoles() throws EntException { + String username = "testUser"; + List roles = Arrays.asList("role1", "role2"); + + authorizationManager.deleteUserRoles(username, roles); + + verify(authorizationDAO).deleteUserRoles(username, roles); + } + + @Test + void shouldDeleteUserGroups() throws EntException { + String username = "testUser"; + List groups = Arrays.asList("group1", "group2"); + + authorizationManager.deleteUserGroups(username, groups); + + verify(authorizationDAO).deleteUserGroups(username, groups); + } + + @Test + void shouldThrowExceptionWhenDaoFailsOnDeleteUserRoles() { + String username = "testUser"; + List roles = Arrays.asList("role1"); + + when(authorizationDAO.deleteUserRoles(anyString(), anyList())).thenThrow(new RuntimeException("DAO error")); + + try { + authorizationManager.deleteUserRoles(username, roles); + } catch (EntException e) { + // expected + } + } + + @Test + void shouldThrowExceptionWhenDaoFailsOnDeleteUserGroups() { + String username = "testUser"; + List groups = Arrays.asList("group1"); + + when(authorizationDAO.deleteUserGroups(anyString(), anyList())).thenThrow(new RuntimeException("DAO error")); + + try { + authorizationManager.deleteUserGroups(username, groups); + } catch (EntException e) { + // expected + } + } +} diff --git a/engine/src/test/java/com/agiletec/aps/system/services/authorization/TestAuthorizationDAO.java b/engine/src/test/java/com/agiletec/aps/system/services/authorization/TestAuthorizationDAO.java new file mode 100644 index 000000000..7309c709f --- /dev/null +++ b/engine/src/test/java/com/agiletec/aps/system/services/authorization/TestAuthorizationDAO.java @@ -0,0 +1,64 @@ +package com.agiletec.aps.system.services.authorization; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.agiletec.aps.BaseTestCase; +import com.agiletec.aps.system.services.group.Group; +import com.agiletec.aps.system.services.role.Role; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import javax.sql.DataSource; +import org.junit.jupiter.api.Test; + +class TestAuthorizationDAO extends BaseTestCase { + + @Test + void testDeleteUserRolesAndGroups() throws Throwable { + DataSource dataSource = (DataSource) this.getApplicationContext().getBean("servDataSource"); + AuthorizationDAO authorizationDAO = new AuthorizationDAO(); + authorizationDAO.setDataSource(dataSource); + + String username = "admin"; // Admin user usually exists in test data + + // 1. Setup: Add some authorizations + Group freeGroup = new Group(); + freeGroup.setName("free"); + Role editorRole = new Role(); + editorRole.setName("editor"); + Authorization auth1 = new Authorization(freeGroup, editorRole); + + Group coachGroup = new Group(); + coachGroup.setName("coach"); + Role pageManagerRole = new Role(); + pageManagerRole.setName("pageManager"); + Authorization auth2 = new Authorization(coachGroup, pageManagerRole); + + authorizationDAO.addUserAuthorizations(username, Arrays.asList(auth1, auth2)); + + Map groups = Map.of("free", freeGroup, "coach", coachGroup); + Map roles = Map.of("editor", editorRole, "pageManager", pageManagerRole); + + List authorizations = authorizationDAO.getUserAuthorizations(username, groups, roles); + assertTrue(containsAuth(authorizations, "free", "editor")); + assertTrue(containsAuth(authorizations, "coach", "pageManager")); + + // 2. Test deleteUserRoles + authorizationDAO.deleteUserRoles(username, Arrays.asList("editor")); + authorizations = authorizationDAO.getUserAuthorizations(username, groups, roles); + assertFalse(containsAuth(authorizations, "free", "editor")); + assertTrue(containsAuth(authorizations, "coach", "pageManager")); + + // 3. Test deleteUserGroups + authorizationDAO.deleteUserGroups(username, Arrays.asList("coach")); + authorizations = authorizationDAO.getUserAuthorizations(username, groups, roles); + assertFalse(containsAuth(authorizations, "coach", "pageManager")); + } + + private boolean containsAuth(List authorizations, String group, String role) { + return authorizations.stream() + .anyMatch(a -> a.getGroup() != null && a.getGroup().getName().equals(group) + && a.getRole() != null && a.getRole().getName().equals(role)); + } +} 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 0a44b20d3..dacab0094 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 @@ -111,6 +111,7 @@ public Authentication attemptAuthentication(final HttpServletRequest request, fi setUserOnContext(request, user, userAuthentication); // TODO optimise to not check on every request + keycloakGroupManager.cleanupManagedAuthorizations(user.getUsername()); keycloakGroupManager.processNewUser(user, bearerToken, true); return userAuthentication; 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 ccc7e824f..ded8a49fa 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 @@ -251,6 +251,8 @@ private void doLogin(final HttpServletRequest request, final HttpServletResponse || tokenResponse.getBody() == null || !tokenResponse.getBody().isActive()) { throw new EntandoTokenException("invalid or expired token", request, "guest"); } + + keycloakGroupManager.cleanupManagedAuthorizations(tokenResponse.getBody().getUsername()); final UserDetails user = providerManager.getUser(tokenResponse.getBody().getUsername()); session.setAttribute(SESSION_PARAM_ACCESS_TOKEN, responseEntity.getBody().getAccessToken()); session.setAttribute(SESSION_PARAM_ID_TOKEN, responseEntity.getBody().getIdToken()); 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 904d72c84..67f8a019b 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 @@ -178,54 +178,17 @@ public void processNewUser(final UserDetails user, final String token, final boo && !profileMappings.isEmpty()) { processProfileAttributes((KeycloakUser) user); } - - this.cleanupManagedAuthorizations(user); - } finally { + } finally { readLock.unlock(); } } - private void cleanupManagedAuthorizations(UserDetails user) { - if ((roles == null || roles.isEmpty()) && (groups == null || groups.isEmpty())) { - return; - } - - final List userAuths = user.getAuthorizations(); - final Set assignedRoles = userAuths.stream() - .map(Authorization::getRole) - .filter(Objects::nonNull) - .map(Role::getName) - .collect(Collectors.toSet()); - final Set assignedGroups = userAuths.stream() - .map(Authorization::getGroup) - .filter(Objects::nonNull) - .map(Group::getName) - .collect(Collectors.toSet()); - - if (roles != null) { - for (String managedRole : roles) { - if (!assignedRoles.contains(managedRole)) { - log.debug("Removing managed role {} from user {}", managedRole, user.getUsername()); - try { - authorizationManager.deleteUserAuthorization(user.getUsername(), null, managedRole); - } catch (Exception e) { - log.error("Error removing managed role {} for user {}", managedRole, user.getUsername(), e); - } - } - } + public void cleanupManagedAuthorizations(final String username) throws EntException { + if (roles != null && !roles.isEmpty()) { + authorizationManager.deleteUserRoles(username, roles); } - - if (groups != null) { - for (String managedGroup : groups) { - if (!assignedGroups.contains(managedGroup)) { - log.debug("Removing managed group {} from user {}", managedGroup, user.getUsername()); - try { - authorizationManager.deleteUserAuthorization(user.getUsername(), managedGroup, null); - } catch (Exception e) { - log.error("Error removing managed group {} for user {}", managedGroup, user.getUsername(), e); - } - } - } + if (groups != null && !groups.isEmpty()) { + authorizationManager.deleteUserGroups(username, groups); } } diff --git a/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java b/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java index b734f7236..d9c8a4294 100644 --- a/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java +++ b/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java @@ -726,48 +726,6 @@ void testAuthAssignmentWhenRoleExistsAndAddRoleFailsWithPersistAuth() throws Exc assertThat(authCaptor.getValue().getRole().getName()).isEqualTo("conflict_role"); } - @Test - void testCleanupManagedAuthorizations() throws Exception { - String xml = "" - + " " - + " managed-role-1" - + " managed-role-2" - + " " - + " " - + " managed-group-1" - + " managed-group-2" - + " " - + ""; - - when(configManager.getConfigItem("dynamicAuthMapping")).thenReturn(xml); - manager.init(); - - List userAuths = new ArrayList<>(); - // L'utente ha managed-role-1 e un ruolo NON gestito - userAuths.add(authorization(null, "managed-role-1")); - userAuths.add(authorization(null, "other-role")); - // L'utente ha managed-group-1 e un gruppo NON gestito - userAuths.add(authorization("managed-group-1", null)); - userAuths.add(authorization("other-group", null)); - - when(userDetails.getAuthorizations()).thenReturn(userAuths); - when(userDetails.getUsername()).thenReturn("test-user"); - - manager.processNewUser(userDetails, null, false); - - // managed-role-2 deve essere rimosso perché gestito ma non presente - verify(authorizationManager).deleteUserAuthorization("test-user", null, "managed-role-2"); - // managed-group-2 deve essere rimosso perché gestito ma non presente - verify(authorizationManager).deleteUserAuthorization("test-user", "managed-group-2", null); - - // managed-role-1 NON deve essere rimosso - verify(authorizationManager, never()).deleteUserAuthorization("test-user", null, "managed-role-1"); - // managed-group-1 NON deve essere rimosso - verify(authorizationManager, never()).deleteUserAuthorization("test-user", "managed-group-1", null); - // Ruoli e gruppi non gestiti non devono essere rimossi - verify(authorizationManager, never()).deleteUserAuthorization("test-user", null, "other-role"); - verify(authorizationManager, never()).deleteUserAuthorization("test-user", "other-group", null); - } @Test void testCleanupManagedAuthorizationsEmptyLists() throws Exception { @@ -798,6 +756,68 @@ void testCleanupManagedAuthorizationsNullLists() throws Exception { verify(authorizationManager, never()).deleteUserAuthorization(anyString(), anyString(), anyString()); } + @Test + void testCleanupManagedAuthorizationsWithRolesAndGroups() throws Exception { + String xml = "" + + " " + + " roleA" + + " roleB" + + " " + + " " + + " groupA" + + " groupB" + + " " + + ""; + + when(configManager.getConfigItem("dynamicAuthMapping")).thenReturn(xml); + + manager.init(); + + String username = "john"; + manager.cleanupManagedAuthorizations(username); + + verify(authorizationManager, times(1)).deleteUserRoles(eq(username), eq(List.of("roleA", "roleB"))); + verify(authorizationManager, times(1)).deleteUserGroups(eq(username), eq(List.of("groupA", "groupB"))); + } + + @Test + void testCleanupManagedAuthorizationsOnlyRoles() throws Exception { + String xml = "" + + " " + + " roleA" + + " " + + ""; + + when(configManager.getConfigItem("dynamicAuthMapping")).thenReturn(xml); + + manager.init(); + + String username = "jane"; + manager.cleanupManagedAuthorizations(username); + + verify(authorizationManager, times(1)).deleteUserRoles(eq(username), eq(List.of("roleA"))); + verify(authorizationManager, never()).deleteUserGroups(anyString(), any()); + } + + @Test + void testCleanupManagedAuthorizationsOnlyGroups() throws Exception { + String xml = "" + + " " + + " groupA" + + " " + + ""; + + when(configManager.getConfigItem("dynamicAuthMapping")).thenReturn(xml); + + manager.init(); + + String username = "mark"; + manager.cleanupManagedAuthorizations(username); + + verify(authorizationManager, times(1)).deleteUserGroups(eq(username), eq(List.of("groupA"))); + verify(authorizationManager, never()).deleteUserRoles(anyString(), any()); + } + private Authorization authorization(final String groupName, final String roleName) { final Group group = new Group(); group.setName(groupName); diff --git a/webapp/pom.xml b/webapp/pom.xml index a80615bd2..84c95c2b3 100644 --- a/webapp/pom.xml +++ b/webapp/pom.xml @@ -49,10 +49,10 @@ false - http://localhost:8081/auth - entando - entando-app - b4b34472-9926-4753-9db8-a50f152df3da + http://localhost:9080/auth + entando-development + entando-core + 930837f0-95b2-4eeb-b303-82a56cac76e6 entando-web From 51cf0a8d4aac1f8621fc67b50198a2184d4f610e Mon Sep 17 00:00:00 2001 From: "Matteo E. Minnai" Date: Tue, 24 Feb 2026 18:09:31 +0100 Subject: [PATCH 24/44] ESB-950 Code quality --- .../authorization/AuthorizationDAO.java | 99 ++++++++++--------- .../authorization/IAuthorizationDAO.java | 4 +- .../AuthorizationManagerTest.java | 17 ++-- 3 files changed, 59 insertions(+), 61 deletions(-) diff --git a/engine/src/main/java/com/agiletec/aps/system/services/authorization/AuthorizationDAO.java b/engine/src/main/java/com/agiletec/aps/system/services/authorization/AuthorizationDAO.java index 96d45e483..c2cb2b0eb 100644 --- a/engine/src/main/java/com/agiletec/aps/system/services/authorization/AuthorizationDAO.java +++ b/engine/src/main/java/com/agiletec/aps/system/services/authorization/AuthorizationDAO.java @@ -21,12 +21,12 @@ import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; +import java.sql.SQLException; import java.sql.Types; import java.util.ArrayList; import java.util.List; import java.util.Map; -import java.util.StringJoiner; import org.apache.commons.collections.CollectionUtils; import org.entando.entando.ent.util.EntLogging.EntLogger; import org.entando.entando.ent.util.EntLogging.EntLogFactory; @@ -160,7 +160,7 @@ public List getUsersByAuthorities(List groupNames, List } @Override - public int deleteUserGroups(String utente, List groups) { + public int deleteUserGroups(String username, List groups) { Connection conn = null; PreparedStatement stat = null; @@ -172,30 +172,11 @@ public int deleteUserGroups(String utente, List groups) { try { conn = this.getConnection(); conn.setAutoCommit(false); + stat = conn.prepareStatement( + generateGroupRemoveSql(groups.size()) + ); - final StringJoiner placeholders = new StringJoiner(", "); - - for (int i = 0; i < groups.size(); i++) { - placeholders.add("?"); - } - - final String sql = DELETE_USER_AUTHORIZATIONS + " AND groupname IN (" - + placeholders + ")"; - - stat = conn.prepareStatement(sql); - - int index = 1; - stat.setString(index++, utente); - - for (String role : groups) { - stat.setString(index++, role); - } - - int rowsDeleted = stat.executeUpdate(); - - conn.commit(); - return rowsDeleted; - + return doDeleteAuth(username, groups, conn, stat); } catch (Exception e) { this.executeRollback(conn); throw new RuntimeException("Error detected while deleting user groups authorizations", e); @@ -204,8 +185,20 @@ public int deleteUserGroups(String utente, List groups) { } } + private String generateGroupRemoveSql(final int size) { + if (size <= 0) { + throw new IllegalArgumentException("Size must be > 0"); + } + + String placeholders = String.join(", ", + java.util.Collections.nCopies(size, "?")); + + return DELETE_USER_AUTHORIZATIONS + + " AND groupname IN (" + placeholders + ")"; + } + @Override - public int deleteUserRoles(String utente, List roles) { + public int deleteUserRoles(String username, List roles) { Connection conn = null; PreparedStatement stat = null; @@ -214,40 +207,48 @@ public int deleteUserRoles(String utente, List roles) { } try { - // Inizio transazione conn = this.getConnection(); conn.setAutoCommit(false); + stat = conn.prepareStatement( + generateRoleRemoveSql(roles.size()) + ); - final StringJoiner placeholders = new StringJoiner(", "); + return doDeleteAuth(username, roles, conn, stat); + } catch (Exception e) { + this.executeRollback(conn); + throw new RuntimeException("Error detected while deleting user role authorizations", e); + } finally { + this.closeDaoResources(null, stat, conn); + } + } - for (int i = 0; i < roles.size(); i++) { - placeholders.add("?"); - } + private String generateRoleRemoveSql(int size) { + if (size <= 0) { + throw new IllegalArgumentException("Size must be > 0"); + } - final String sql = DELETE_USER_AUTHORIZATIONS + " AND rolename IN (" - + placeholders + ")"; + String placeholders = String.join(", ", + java.util.Collections.nCopies(size, "?")); - stat = conn.prepareStatement(sql); + return DELETE_USER_AUTHORIZATIONS + + " AND rolename IN (" + placeholders + ")"; + } - int index = 1; - stat.setString(index++, utente); + private int doDeleteAuth(String username, List roles, Connection conn, PreparedStatement stat) + throws SQLException { + int index = 1; + stat.setString(index++, username); - for (String role : roles) { - stat.setString(index++, role); - } + for (String role : roles) { + stat.setString(index++, role); + } - int rowsDeleted = stat.executeUpdate(); + int rowsDeleted = stat.executeUpdate(); - conn.commit(); - return rowsDeleted; - } catch (Exception e) { - this.executeRollback(conn); - throw new RuntimeException("Error detected while deleting user role authorizations", e); - } finally { - this.closeDaoResources(null, stat, conn); - } + conn.commit(); + return rowsDeleted; } - + @Override protected String getTableFieldName(String metadataFieldKey) { return metadataFieldKey; diff --git a/engine/src/main/java/com/agiletec/aps/system/services/authorization/IAuthorizationDAO.java b/engine/src/main/java/com/agiletec/aps/system/services/authorization/IAuthorizationDAO.java index bb12e7591..b2624415c 100644 --- a/engine/src/main/java/com/agiletec/aps/system/services/authorization/IAuthorizationDAO.java +++ b/engine/src/main/java/com/agiletec/aps/system/services/authorization/IAuthorizationDAO.java @@ -40,7 +40,7 @@ public interface IAuthorizationDAO { public List getUsersByAuthorities(List groupNames, List roleNames); - int deleteUserGroups(String utente, List groups); + int deleteUserGroups(String username, List groups); - int deleteUserRoles(String utente, List roles); + int deleteUserRoles(String username, List roles); } diff --git a/engine/src/test/java/com/agiletec/aps/system/services/authorization/AuthorizationManagerTest.java b/engine/src/test/java/com/agiletec/aps/system/services/authorization/AuthorizationManagerTest.java index cea228c04..f0b962a4e 100644 --- a/engine/src/test/java/com/agiletec/aps/system/services/authorization/AuthorizationManagerTest.java +++ b/engine/src/test/java/com/agiletec/aps/system/services/authorization/AuthorizationManagerTest.java @@ -1,5 +1,6 @@ package com.agiletec.aps.system.services.authorization; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.verify; @@ -55,11 +56,9 @@ void shouldThrowExceptionWhenDaoFailsOnDeleteUserRoles() { when(authorizationDAO.deleteUserRoles(anyString(), anyList())).thenThrow(new RuntimeException("DAO error")); - try { - authorizationManager.deleteUserRoles(username, roles); - } catch (EntException e) { - // expected - } + assertThrows(EntException.class, () -> + authorizationManager.deleteUserRoles(username, roles) + ); } @Test @@ -69,10 +68,8 @@ void shouldThrowExceptionWhenDaoFailsOnDeleteUserGroups() { when(authorizationDAO.deleteUserGroups(anyString(), anyList())).thenThrow(new RuntimeException("DAO error")); - try { - authorizationManager.deleteUserGroups(username, groups); - } catch (EntException e) { - // expected - } + assertThrows(EntException.class, () -> + authorizationManager.deleteUserGroups(username, groups) + ); } } From 5f0b6397e472fc7a3f27789b1dfb6b49f7b6ca69 Mon Sep 17 00:00:00 2001 From: "Matteo E. Minnai" Date: Wed, 25 Feb 2026 12:27:37 +0100 Subject: [PATCH 25/44] ESB-950 Refactored database usage --- .../services/authorization/Authorization.java | 7 + .../aps/system/services/group/Group.java | 1 + .../KeycloakAuthenticationFilter.java | 2 +- .../keycloak/filter/KeycloakFilter.java | 2 +- .../KeycloakAuthorizationManager.java | 212 +++++++++++++----- 5 files changed, 172 insertions(+), 52 deletions(-) diff --git a/engine/src/main/java/com/agiletec/aps/system/services/authorization/Authorization.java b/engine/src/main/java/com/agiletec/aps/system/services/authorization/Authorization.java index 75199820e..73c63ad37 100644 --- a/engine/src/main/java/com/agiletec/aps/system/services/authorization/Authorization.java +++ b/engine/src/main/java/com/agiletec/aps/system/services/authorization/Authorization.java @@ -18,6 +18,7 @@ import java.io.Serializable; +import java.util.Objects; import javax.xml.bind.annotation.XmlElement; import javax.xml.bind.annotation.XmlRootElement; import org.springframework.security.core.GrantedAuthority; @@ -67,6 +68,8 @@ protected void setRole(Role role) { @Override public boolean equals(Object obj) { + if (this == obj) return true; + if (obj == null || getClass() != obj.getClass()) return false; Authorization other = (Authorization) obj; boolean isGroupsEquals = (null == this.getGroup() && null == other.getGroup()) || (null != this.getGroup() && null != other.getGroup() && other.getGroup().equals(this.getGroup())); @@ -75,4 +78,8 @@ public boolean equals(Object obj) { return (isRolesEquals && isGroupsEquals); } + @Override + public int hashCode() { + return Objects.hash(getGroup(), getRole()); + } } diff --git a/engine/src/main/java/com/agiletec/aps/system/services/group/Group.java b/engine/src/main/java/com/agiletec/aps/system/services/group/Group.java index 61fc01c0f..bebdea3d3 100644 --- a/engine/src/main/java/com/agiletec/aps/system/services/group/Group.java +++ b/engine/src/main/java/com/agiletec/aps/system/services/group/Group.java @@ -54,6 +54,7 @@ public void setDescr(String description) { @Override public boolean equals(Object obj) { + if (obj == this) return true; if (null != obj && (obj instanceof Group)) { return this.getName().equals(((Group) obj).getName()); } else { 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 dacab0094..ae1591171 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 @@ -111,7 +111,7 @@ public Authentication attemptAuthentication(final HttpServletRequest request, fi setUserOnContext(request, user, userAuthentication); // TODO optimise to not check on every request - keycloakGroupManager.cleanupManagedAuthorizations(user.getUsername()); +// keycloakGroupManager.cleanupManagedAuthorizations(user.getUsername()); keycloakGroupManager.processNewUser(user, bearerToken, true); return userAuthentication; 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 ded8a49fa..03a47590f 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 @@ -252,7 +252,7 @@ private void doLogin(final HttpServletRequest request, final HttpServletResponse throw new EntandoTokenException("invalid or expired token", request, "guest"); } - keycloakGroupManager.cleanupManagedAuthorizations(tokenResponse.getBody().getUsername()); +// keycloakGroupManager.cleanupManagedAuthorizations(tokenResponse.getBody().getUsername()); final UserDetails user = providerManager.getUser(tokenResponse.getBody().getUsername()); session.setAttribute(SESSION_PARAM_ACCESS_TOKEN, responseEntity.getBody().getAccessToken()); session.setAttribute(SESSION_PARAM_ID_TOKEN, responseEntity.getBody().getIdToken()); 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 67f8a019b..b05cd891a 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 @@ -90,8 +90,10 @@ public KeycloakAuthorizationManager(final KeycloakConfiguration configuration, @Override public void init() throws Exception { writeLock.lock(); + profileMappings = new ArrayList<>(); jwtMappings = new ArrayList<>(); + try { String xml = configManager.getConfigItem("dynamicAuthMapping"); if (StringUtils.isNotBlank(xml)) { @@ -102,6 +104,7 @@ public void init() throws Exception { final Map> partitioned = dynConf.mapping.stream() .filter(this::isValid) + .filter(m -> m.enabled) .collect(Collectors.partitioningBy( item -> item.kind.isJwtMapping() )); @@ -116,7 +119,10 @@ public void init() throws Exception { } } if (profileMappings != null) { - profileMappings.forEach(m -> log.debug("mapping active: {}", m.toString())); + profileMappings.forEach(m -> log.debug("profile mapping active: {}", m.toString())); + } + if (jwtMappings != null) { + jwtMappings.forEach(m -> log.debug("jwt mapping active: {}", m.toString())); } } catch (Exception e) { log.error("Error initializing KeycloakAuthorizationManager", e); @@ -162,22 +168,100 @@ private boolean isValid(DynamicMappingElement elem) { return true; } - public void processNewUser(final UserDetails user, final String token, final boolean decode) { + public synchronized void processNewUser(final UserDetails user, final String token, final boolean decode) { processNewUser(user); readLock.lock(); try { + // Authorizations coming from dynamic mapping (that is, external sources) + final List dynamicAuthorizations = new ArrayList<>(); + // process path role claims, if any... if (StringUtils.isNotBlank(token) && !jwtMappings.isEmpty()) { for (DynamicMappingElement cur: jwtMappings) { - processJwtClaimAttributes(user, token, decode, cur); + dynamicAuthorizations.addAll(processJwtClaimAttributes(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); + dynamicAuthorizations.addAll(processProfileAttributes((KeycloakUser) user)); + } + // se l'autorizzazione dinamica non è già assegnata all'utente allora va aggiunta + List toAdd = dynamicAuthorizations + .stream() + .filter(a -> { + final String groupName = a.getGroup() != null ? a.getGroup().getName() : null; + final String roleName = a.getRole() != null ? a.getRole().getName() : null; + + if (isAlreadyAssigned((KeycloakUser) user, roleName, groupName)) { + return false; + } else { + return true; + } + }) + .collect(Collectors.toList()); + // list of the _managed_ authorizations currently assigned to the user + List existingAuths = user.getAuthorizations() + .stream() + .filter(a -> (a.getGroup() != null && groups.contains(a.getRole().getName()) + || (a.getRole() != null && roles.contains(a.getRole().getName()))) + ) + .collect(Collectors.toList()); + // se l'autorizzazione esistente non è contenuta nelle dynamic auths allora va cancellata + List toDelete = existingAuths + .stream() + .filter(a -> { + return !dynamicAuthorizations.stream() + .anyMatch(d -> d.equals(a)); + }) + .collect(Collectors.toList()); + // finally + for (Authorization authorization : toAdd) { + persistAuthIfMissing((KeycloakUser) user, authorization); + + user.addAuthorization(authorization); } +// for (Authorization authorization: toDelete) { +// List rolesToDelete = new ArrayList<>(); +// List groupsToDelete = new ArrayList<>(); +// +// if (authorization.getRole() != null) { +// rolesToDelete.add(authorization.getRole().getName()); +// } +// if (authorization.getGroup() != null) { +// groupsToDelete.add(authorization.getGroup().getName()); +// } +// +// } + + System.out.println("-------------------\n"); + dynamicAuthorizations.forEach(n-> { + final String groupName = n.getGroup() != null ? n.getGroup().getName() : null; + final String roleName = n.getRole() != null ? n.getRole().getName() : null; + + System.out.println("JWT " + user.getUsername() + " role " + roleName + " group " + groupName); + }); + existingAuths.forEach(n-> { + final String groupName = n.getGroup() != null ? n.getGroup().getName() : null; + final String roleName = n.getRole() != null ? n.getRole().getName() : null; + + System.out.println("USER " + user.getUsername() + " role " + roleName + " group " + groupName); + }); + toAdd.forEach(n-> { + final String groupName = n.getGroup() != null ? n.getGroup().getName() : null; + final String roleName = n.getRole() != null ? n.getRole().getName() : null; + + System.out.println("ADD " + user.getUsername() + " role " + roleName + " group " + groupName); + }); + toDelete.forEach(d-> { + final String groupName = d.getGroup() != null ? d.getGroup().getName() : null; + final String roleName = d.getRole() != null ? d.getRole().getName() : null; + + System.out.println("DELETE " + user.getUsername() + " role " + roleName + " group " + groupName); + }); + } catch (EntException e) { + throw new RuntimeException(e); } finally { readLock.unlock(); } @@ -198,24 +282,28 @@ public void cleanupManagedAuthorizations(final String username) throws EntExcept * @param token access token * @param decode is true the access token is decoded from the base64 form * @param claimMapper the mapping configuration + * @return the list of authorizations extracted from the JWT */ - private void processJwtClaimAttributes(final UserDetails user, final String token, final boolean decode, final DynamicMappingElement claimMapper) { + private List processJwtClaimAttributes(final UserDetails user, final String token, final boolean decode, final DynamicMappingElement claimMapper) { final List authorizations = oidcMappingService.extractAuthorizationsFromJwt(token, decode, claimMapper, user.getUsername()); + List jwtAuthorizations = new ArrayList<>(); if (user instanceof KeycloakUser && !authorizations.isEmpty()) { KeycloakUser kcUser = (KeycloakUser) user; if (claimMapper.kind == DynamicMappingKind.ROLECLAIM) { - finalizeRoleAssociation(kcUser, claimMapper, authorizations); + jwtAuthorizations.addAll(finalizeRoleAssociation(kcUser, claimMapper, authorizations)); } else if (claimMapper.kind == DynamicMappingKind.GROUPCLAIM) { - finalizeGroupAssociation(kcUser, claimMapper, authorizations); + jwtAuthorizations.addAll(finalizeGroupAssociation(kcUser, claimMapper, authorizations)); } else { - finalizeGroupRoleAssociation(kcUser, claimMapper, authorizations); + jwtAuthorizations.addAll(finalizeGroupRoleAssociation(kcUser, claimMapper, authorizations)); } } + return jwtAuthorizations; } - private void finalizeGroupRoleAssociation(KeycloakUser user, DynamicMappingElement elem, List authorizations) { - if (authorizations == null) return; + private List finalizeGroupRoleAssociation(KeycloakUser user, DynamicMappingElement elem, List authorizations) { + List result = new ArrayList<>(); + if (authorizations == null) return result; for (String candidate : authorizations) { try { @@ -228,7 +316,7 @@ private void finalizeGroupRoleAssociation(KeycloakUser user, DynamicMappingEleme if (tokens.length < 2) { // treat as a role - finalizeRoleAssociation(user, elem, List.of(candidate.trim())); + result.addAll(finalizeRoleAssociation(user, elem, List.of(candidate.trim()))); continue; } @@ -240,11 +328,15 @@ private void finalizeGroupRoleAssociation(KeycloakUser user, DynamicMappingEleme continue; } - finalizeAssociation(user, elem, roleName, groupName, candidate); + Authorization auth = finalizeAssociation(user, elem, roleName, groupName); + if (auth != null) { + result.add(auth); + } } catch (Exception e) { log.error("Error processing dynamic group-role '{}' for user {}", candidate, user.getUsername(), e); } } + return result; } private void processNewUser(final UserDetails user) { @@ -325,22 +417,26 @@ private Role findOrCreateRole(final String roleName) { * Map dynamically, optionally persisting, authorization coming from the user profile in * keycloak * @param user the currently logged user + * @return the list of authorizations extracted from the user profile */ - private void processProfileAttributes(final KeycloakUser user) { + private List processProfileAttributes(final KeycloakUser user) { + List result = new ArrayList<>(); profileMappings.forEach(m -> { if (m.kind == ROLE) { - doProcessRole(user, m); + result.addAll(doProcessRole(user, m)); } if (m.kind == GROUP) { - doProcessGroup(user, m); + result.addAll(doProcessGroup(user, m)); } if (m.kind == ROLEGROUP) { - doProcessRoleGroup(user, m); + result.addAll(doProcessRoleGroup(user, m)); } }); + return result; } - private void doProcessRoleGroup(KeycloakUser user, DynamicMappingElement elem) { + private List doProcessRoleGroup(KeycloakUser user, DynamicMappingElement elem) { + List result = new ArrayList<>(); final String separator = StringUtils.isBlank(elem.separator) ? DEFAULT_SEPARATOR : elem.separator; @@ -348,17 +444,21 @@ private void doProcessRoleGroup(KeycloakUser user, DynamicMappingElement elem) { final List authorizations = oidcMappingService.extractAuthorizationsFromProfile(user, elem); if (authorizations == null) { - return; + return result; } for (String groupRoleToken : authorizations) { - parseAuthForRoleGroup(user, elem, groupRoleToken, separator); + Authorization auth = parseAuthForRoleGroup(user, elem, groupRoleToken, separator); + if (auth != null) { + result.add(auth); + } } } catch (Exception e) { log.error("error processing dynamic GRUOPROLE association", e); } + return result; } - private void parseAuthForRoleGroup(KeycloakUser user, DynamicMappingElement elem, String groupRoleToken, String separator) + private Authorization parseAuthForRoleGroup(KeycloakUser user, DynamicMappingElement elem, String groupRoleToken, String separator) throws EntException { final String[] tokens = groupRoleToken.split(separator); @@ -366,13 +466,13 @@ private void parseAuthForRoleGroup(KeycloakUser user, DynamicMappingElement elem || StringUtils.isBlank(tokens[0]) || StringUtils.isBlank(tokens[1])) { log.error("invalid dynamic config configuration detected"); - return; + return null; } final String groupName = tokens[1]; final String roleName = tokens[0]; - finalizeAssociation(user, elem, roleName, groupName, groupRoleToken, false); + return finalizeAssociation(user, elem, roleName, groupName, false); } private Group createTransientGroup(String groupName) { @@ -386,26 +486,32 @@ private Group createTransientGroup(String groupName) { * Process the dynamic Role authorization for the given user * @param user the currently logging-in user * @param elem a single dynamic configuration + * @return the list of authorizations extracted from the user profile */ - private void doProcessRole(KeycloakUser user, DynamicMappingElement elem) { + private List doProcessRole(KeycloakUser user, DynamicMappingElement elem) { final List authorizations = oidcMappingService.extractAuthorizationsFromProfile(user, elem); - finalizeRoleAssociation(user, elem, authorizations); + return finalizeRoleAssociation(user, elem, authorizations); } - private void finalizeRoleAssociation(KeycloakUser user, DynamicMappingElement elem, List authorizations) { - if (authorizations == null) return; + private List finalizeRoleAssociation(KeycloakUser user, DynamicMappingElement elem, List authorizations) { + List result = new ArrayList<>(); + if (authorizations == null) return result; for (String roleName : authorizations) { try { - finalizeAssociation(user, elem, roleName, null, roleName); + Authorization auth = finalizeAssociation(user, elem, roleName, null); + if (auth != null) { + result.add(auth); + } } catch (Exception e) { log.error("Error processing dynamic role '{}' for user {}", roleName, user.getUsername(), e); } } + return result; } - private void finalizeAssociation(KeycloakUser user, DynamicMappingElement elem, String roleName, String groupName, String originalCandidate) throws EntException { - finalizeAssociation(user, elem, roleName, groupName, originalCandidate, true); + private Authorization finalizeAssociation(KeycloakUser user, DynamicMappingElement elem, String roleName, String groupName) throws EntException { + return finalizeAssociation(user, elem, roleName, groupName, true); } private boolean isIgnored(String name) { @@ -415,25 +521,25 @@ private boolean isIgnored(String name) { return ignore.contains(name.trim()); } - private void finalizeAssociation(KeycloakUser user, DynamicMappingElement elem, String roleName, String groupName, String originalCandidate, boolean createRoleIfMissing) throws EntException { + private Authorization finalizeAssociation(KeycloakUser user, DynamicMappingElement elem, String roleName, String groupName, + boolean createRoleIfMissing) throws EntException { if (isIgnored(roleName) || isIgnored(groupName)) { log.info("Role {} or Group {} is in the ignore list. Skipping assignment for user {}", roleName, groupName, user.getUsername()); - return; + return null; } - if (isAlreadyAssigned(user, roleName, groupName)) { - log.debug("Role {} and group {} already assigned to user {}", roleName, groupName, user.getUsername()); - return; - } +// if (isAlreadyAssigned(user, roleName, groupName)) { +// log.debug("Role {} and group {} already assigned to user {}", roleName, groupName, user.getUsername()); +// return null; +// } Authorization auth = createAuthorization(elem, roleName, groupName, createRoleIfMissing); - - if (elem.persist == PersistKind.FULL) { - persistAuthIfMissing(user, auth); - } - - user.addAuthorization(auth); - log.info("Successfully assigned {} to user {}", originalCandidate, user.getUsername()); +// if (elem.persist == PersistKind.FULL) { +// persistAuthIfMissing(user, auth); +// } +// user.addAuthorization(auth); +// log.info("Successfully assigned {} to user {}", originalCandidate, user.getUsername()); + return auth; } private Authorization createAuthorization(DynamicMappingElement elem, String roleName, String groupName, boolean createRoleIfMissing) { @@ -492,32 +598,38 @@ private boolean isAlreadyAssigned(final KeycloakUser user, final String roleName * Process the dynamic Group authorization for the given user * @param user the currently logging-in user * @param elem a single dynamic configuration + * @return the list of authorizations extracted from the user profile */ - private void doProcessGroup(KeycloakUser user, DynamicMappingElement elem) { + private List doProcessGroup(KeycloakUser user, DynamicMappingElement elem) { final List authorizations = oidcMappingService.extractAuthorizationsFromProfile(user, elem); if (authorizations == null) { - return; + return new ArrayList<>(); } - finalizeGroupAssociation(user, elem, authorizations); + return finalizeGroupAssociation(user, elem, authorizations); } - private void finalizeGroupAssociation(KeycloakUser user, DynamicMappingElement elem, List authorizations) { - if (authorizations == null) return; + private List finalizeGroupAssociation(KeycloakUser user, DynamicMappingElement elem, List authorizations) { + List result = new ArrayList<>(); + if (authorizations == null) return result; for (String groupName : authorizations) { try { - finalizeAssociation(user, elem, null, groupName, groupName); + Authorization auth = finalizeAssociation(user, elem, null, groupName); + if (auth != null) { + result.add(auth); + } } catch (Exception e) { log.error("Error processing dynamic group '{}' for user {}", groupName, user.getUsername(), e); } } + return result; } /** - * To avoid creating duplicate records, we check if the authorization already exists. - * In a replicated environment or under high concurrency, there is still the possibility + * To avoid creating duplicate records, we check if the authorization already exists. + * In a replicated environment or under high concurrency, there is still the possibility * to attempt to create multiple, identical associations. This is handled by a database * unique constraint and a try-catch block. * @param user the user being processed From 64499799efbcb2780bd960039eeadb7f4d2fffd2 Mon Sep 17 00:00:00 2001 From: "Matteo E. Minnai" Date: Wed, 25 Feb 2026 12:32:40 +0100 Subject: [PATCH 26/44] ESB-950 Cosmetic changes --- .../KeycloakAuthorizationManager.java | 26 +++++-------------- 1 file changed, 6 insertions(+), 20 deletions(-) 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 b05cd891a..b1c0d3418 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 @@ -194,11 +194,8 @@ public synchronized void processNewUser(final UserDetails user, final String tok final String groupName = a.getGroup() != null ? a.getGroup().getName() : null; final String roleName = a.getRole() != null ? a.getRole().getName() : null; - if (isAlreadyAssigned((KeycloakUser) user, roleName, groupName)) { - return false; - } else { - return true; - } + assert user instanceof KeycloakUser; + return !isAlreadyAssigned((KeycloakUser) user, roleName, groupName); }) .collect(Collectors.toList()); // list of the _managed_ authorizations currently assigned to the user @@ -212,8 +209,8 @@ public synchronized void processNewUser(final UserDetails user, final String tok List toDelete = existingAuths .stream() .filter(a -> { - return !dynamicAuthorizations.stream() - .anyMatch(d -> d.equals(a)); + return dynamicAuthorizations.stream() + .noneMatch(d -> d.equals(a)); }) .collect(Collectors.toList()); // finally @@ -522,24 +519,13 @@ private boolean isIgnored(String name) { } private Authorization finalizeAssociation(KeycloakUser user, DynamicMappingElement elem, String roleName, String groupName, - boolean createRoleIfMissing) throws EntException { + boolean createRoleIfMissing) { if (isIgnored(roleName) || isIgnored(groupName)) { log.info("Role {} or Group {} is in the ignore list. Skipping assignment for user {}", roleName, groupName, user.getUsername()); return null; } -// if (isAlreadyAssigned(user, roleName, groupName)) { -// log.debug("Role {} and group {} already assigned to user {}", roleName, groupName, user.getUsername()); -// return null; -// } - - Authorization auth = createAuthorization(elem, roleName, groupName, createRoleIfMissing); -// if (elem.persist == PersistKind.FULL) { -// persistAuthIfMissing(user, auth); -// } -// user.addAuthorization(auth); -// log.info("Successfully assigned {} to user {}", originalCandidate, user.getUsername()); - return auth; + return createAuthorization(elem, roleName, groupName, createRoleIfMissing); } private Authorization createAuthorization(DynamicMappingElement elem, String roleName, String groupName, boolean createRoleIfMissing) { From dea0fca4938445a0b1f5f22a80a818efd19ca8bb Mon Sep 17 00:00:00 2001 From: "Matteo E. Minnai" Date: Wed, 25 Feb 2026 19:45:26 +0100 Subject: [PATCH 27/44] ESB-950 Refactored logic to minimize DB I/O --- .../authorization/AuthorizationDAO.java | 116 ++- .../authorization/AuthorizationManager.java | 18 +- .../authorization/IAuthorizationDAO.java | 6 +- .../authorization/IAuthorizationManager.java | 10 +- .../AuthorizationManagerTest.java | 42 +- .../authorization/TestAuthorizationDAO.java | 122 +++- .../KeycloakAuthorizationManager.java | 281 +++---- .../services/mapping/DynamicMapping.java | 10 +- .../mapping/DynamicMappingElement.java | 1 - .../port/clob/production/sysconfig_kc.xml | 93 +-- ...ycloakAuthorizationManagerComplexTest.java | 527 ++++++++++++++ .../KeycloakAuthorizationManagerTest.java | 683 ++++++++++++------ 12 files changed, 1361 insertions(+), 548 deletions(-) create mode 100644 keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerComplexTest.java diff --git a/engine/src/main/java/com/agiletec/aps/system/services/authorization/AuthorizationDAO.java b/engine/src/main/java/com/agiletec/aps/system/services/authorization/AuthorizationDAO.java index c2cb2b0eb..01abadfea 100644 --- a/engine/src/main/java/com/agiletec/aps/system/services/authorization/AuthorizationDAO.java +++ b/engine/src/main/java/com/agiletec/aps/system/services/authorization/AuthorizationDAO.java @@ -160,93 +160,73 @@ public List getUsersByAuthorities(List groupNames, List } @Override - public int deleteUserGroups(String username, List groups) { - + public int deleteUserAuthorizationByGroupAndRole(final String username, final List groups, + final List roles) { + final boolean hasRoles = roles != null && !roles.isEmpty(); + final boolean hasGroups = groups != null && !groups.isEmpty(); Connection conn = null; PreparedStatement stat = null; - if (groups == null || groups.isEmpty()) { - return 0; - } - try { conn = this.getConnection(); conn.setAutoCommit(false); - stat = conn.prepareStatement( - generateGroupRemoveSql(groups.size()) - ); + stat = conn.prepareStatement(createSqlForAuthDeletion(username, groups, roles)); - return doDeleteAuth(username, groups, conn, stat); + // username + int index = 1; + stat.setString(index++, username); + // groups + if (hasGroups) { + for (String role : groups) { + stat.setString(index++, role); + } + } + // roles + if (hasRoles) { + for (String role : roles) { + stat.setString(index++, role); + } + } + final int rowsDeleted = stat.executeUpdate(); + conn.commit(); + return rowsDeleted; } catch (Exception e) { this.executeRollback(conn); - throw new RuntimeException("Error detected while deleting user groups authorizations", e); + throw new RuntimeException("Error detected while deleting user authorizations", e); } finally { this.closeDaoResources(null, stat, conn); } } - private String generateGroupRemoveSql(final int size) { - if (size <= 0) { - throw new IllegalArgumentException("Size must be > 0"); - } - - String placeholders = String.join(", ", - java.util.Collections.nCopies(size, "?")); + private String createSqlForAuthDeletion(final String username, final List groups, final List roles) { + final StringBuilder sb = new StringBuilder(DELETE_USER_AUTHORIZATIONS); + final boolean hasRoles = roles != null && !roles.isEmpty(); + final boolean hasGroups = groups != null && !groups.isEmpty(); - return DELETE_USER_AUTHORIZATIONS + - " AND groupname IN (" + placeholders + ")"; - } + sb.append("AND ("); // apertura AND - @Override - public int deleteUserRoles(String username, List roles) { - Connection conn = null; - PreparedStatement stat = null; + if (hasGroups) { + final String placeholders = String.join(", ", + java.util.Collections.nCopies(groups.size(), "?")); - if (roles == null || roles.isEmpty()) { - return 0; - } - - try { - conn = this.getConnection(); - conn.setAutoCommit(false); - stat = conn.prepareStatement( - generateRoleRemoveSql(roles.size()) - ); - - return doDeleteAuth(username, roles, conn, stat); - } catch (Exception e) { - this.executeRollback(conn); - throw new RuntimeException("Error detected while deleting user role authorizations", e); - } finally { - this.closeDaoResources(null, stat, conn); - } - } - - private String generateRoleRemoveSql(int size) { - if (size <= 0) { - throw new IllegalArgumentException("Size must be > 0"); + sb.append("groupname IN ( "); + sb.append(placeholders); + sb.append(") "); // chiusura groupname + // append OR if needed + if (hasRoles) { + sb.append("OR "); + } } - - String placeholders = String.join(", ", - java.util.Collections.nCopies(size, "?")); - - return DELETE_USER_AUTHORIZATIONS + - " AND rolename IN (" + placeholders + ")"; - } - - private int doDeleteAuth(String username, List roles, Connection conn, PreparedStatement stat) - throws SQLException { - int index = 1; - stat.setString(index++, username); - - for (String role : roles) { - stat.setString(index++, role); + if (hasRoles) { + final String placeholders = String.join(", ", + java.util.Collections.nCopies(roles.size(), "?")); + sb.append("rolename IN ( "); + sb.append(placeholders); + sb.append(") "); // chiusura rolename } - - int rowsDeleted = stat.executeUpdate(); - - conn.commit(); - return rowsDeleted; + sb.append(")"); // chiusura AND + System.out.println("\n\n>>> " + sb.toString()); + return sb.toString(); } @Override 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 1caaed7e1..ca3a04ee3 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 @@ -638,22 +638,12 @@ public void deleteUser(Object key) { } @Override - public void deleteUserRoles(String username, List roles) throws EntException { + public void deleteUserAuthorizationByGroupAndRole(String username, List groups, List roles) throws EntException { try { - this.getAuthorizationDAO().deleteUserRoles(username, roles); + this.getAuthorizationDAO().deleteUserAuthorizationByGroupAndRole(username, groups, roles); } catch (Throwable t) { - _logger.error("Error deleting user roles for user '{}'", username, t); - throw new EntException("Error deleting user roles for user " + username, t); - } - } - - @Override - public void deleteUserGroups(String username, List groups) throws EntException { - try { - this.getAuthorizationDAO().deleteUserGroups(username, groups); - } catch (Throwable t) { - _logger.error("Error deleting user groups for user '{}'", username, t); - throw new EntException("Error deleting user groups for user " + username, t); + _logger.error("Error deleting user authorization by group and role for user '{}'", username, t); + throw new EntException("Error deleting user authorization by group and role for user " + username, t); } } diff --git a/engine/src/main/java/com/agiletec/aps/system/services/authorization/IAuthorizationDAO.java b/engine/src/main/java/com/agiletec/aps/system/services/authorization/IAuthorizationDAO.java index b2624415c..89e45c2d2 100644 --- a/engine/src/main/java/com/agiletec/aps/system/services/authorization/IAuthorizationDAO.java +++ b/engine/src/main/java/com/agiletec/aps/system/services/authorization/IAuthorizationDAO.java @@ -16,8 +16,6 @@ import com.agiletec.aps.system.services.group.Group; import com.agiletec.aps.system.services.role.Role; -import java.sql.Connection; -import java.sql.SQLException; import java.util.List; import java.util.Map; @@ -40,7 +38,5 @@ public interface IAuthorizationDAO { public List getUsersByAuthorities(List groupNames, List roleNames); - int deleteUserGroups(String username, List groups); - - int deleteUserRoles(String username, List roles); + int deleteUserAuthorizationByGroupAndRole(String username, List groups, List roles); } diff --git a/engine/src/main/java/com/agiletec/aps/system/services/authorization/IAuthorizationManager.java b/engine/src/main/java/com/agiletec/aps/system/services/authorization/IAuthorizationManager.java index 174a36641..586bb47ca 100644 --- a/engine/src/main/java/com/agiletec/aps/system/services/authorization/IAuthorizationManager.java +++ b/engine/src/main/java/com/agiletec/aps/system/services/authorization/IAuthorizationManager.java @@ -177,8 +177,11 @@ public interface IAuthorizationManager { public List getUsersByAuthority(IApsAuthority authority, boolean includeAdmin) throws EntException; public List getUsersByAuthorities(String groupName, String roleName, boolean includeAdmin) throws EntException; - - public List getUsersByRole(IApsAuthority authority, boolean includeAdmin) throws EntException; + + void deleteUserAuthorizationByGroupAndRole(String username, List groups, + List roles) throws EntException; + + public List getUsersByRole(IApsAuthority authority, boolean includeAdmin) throws EntException; public List getUsersByRole(String roleName, boolean includeAdmin) throws EntException; @@ -186,7 +189,4 @@ public interface IAuthorizationManager { public List getUsersByGroup(String groupName, boolean includeAdmin) throws EntException; - void deleteUserRoles(String username, List roles) throws EntException; - - void deleteUserGroups(String username, List groups) throws EntException; } diff --git a/engine/src/test/java/com/agiletec/aps/system/services/authorization/AuthorizationManagerTest.java b/engine/src/test/java/com/agiletec/aps/system/services/authorization/AuthorizationManagerTest.java index f0b962a4e..a914fb6b4 100644 --- a/engine/src/test/java/com/agiletec/aps/system/services/authorization/AuthorizationManagerTest.java +++ b/engine/src/test/java/com/agiletec/aps/system/services/authorization/AuthorizationManagerTest.java @@ -9,7 +9,6 @@ import java.util.Arrays; import java.util.List; import org.entando.entando.ent.exception.EntException; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -25,51 +24,28 @@ class AuthorizationManagerTest { @InjectMocks private AuthorizationManager authorizationManager; - @BeforeEach - void setUp() { - } - - @Test - void shouldDeleteUserRoles() throws EntException { - String username = "testUser"; - List roles = Arrays.asList("role1", "role2"); - - authorizationManager.deleteUserRoles(username, roles); - - verify(authorizationDAO).deleteUserRoles(username, roles); - } - @Test - void shouldDeleteUserGroups() throws EntException { + void shouldDeleteUserAuthorizationByGroupAndRole() throws EntException { String username = "testUser"; List groups = Arrays.asList("group1", "group2"); + List roles = Arrays.asList("role1", "role2"); - authorizationManager.deleteUserGroups(username, groups); + authorizationManager.deleteUserAuthorizationByGroupAndRole(username, groups, roles); - verify(authorizationDAO).deleteUserGroups(username, groups); + verify(authorizationDAO).deleteUserAuthorizationByGroupAndRole(username, groups, roles); } @Test - void shouldThrowExceptionWhenDaoFailsOnDeleteUserRoles() { + void shouldThrowExceptionWhenDaoFailsOnDeleteUserAuthorizationByGroupAndRole() { String username = "testUser"; + List groups = Arrays.asList("group1"); List roles = Arrays.asList("role1"); - - when(authorizationDAO.deleteUserRoles(anyString(), anyList())).thenThrow(new RuntimeException("DAO error")); - assertThrows(EntException.class, () -> - authorizationManager.deleteUserRoles(username, roles) - ); - } - - @Test - void shouldThrowExceptionWhenDaoFailsOnDeleteUserGroups() { - String username = "testUser"; - List groups = Arrays.asList("group1"); - - when(authorizationDAO.deleteUserGroups(anyString(), anyList())).thenThrow(new RuntimeException("DAO error")); + when(authorizationDAO.deleteUserAuthorizationByGroupAndRole(anyString(), anyList(), anyList())) + .thenThrow(new RuntimeException("DAO error")); assertThrows(EntException.class, () -> - authorizationManager.deleteUserGroups(username, groups) + authorizationManager.deleteUserAuthorizationByGroupAndRole(username, groups, roles) ); } } diff --git a/engine/src/test/java/com/agiletec/aps/system/services/authorization/TestAuthorizationDAO.java b/engine/src/test/java/com/agiletec/aps/system/services/authorization/TestAuthorizationDAO.java index 7309c709f..536d8b06e 100644 --- a/engine/src/test/java/com/agiletec/aps/system/services/authorization/TestAuthorizationDAO.java +++ b/engine/src/test/java/com/agiletec/aps/system/services/authorization/TestAuthorizationDAO.java @@ -15,50 +15,142 @@ class TestAuthorizationDAO extends BaseTestCase { @Test - void testDeleteUserRolesAndGroups() throws Throwable { + void testDeleteUserAuthorizationByGroupAndRole() throws Throwable { DataSource dataSource = (DataSource) this.getApplicationContext().getBean("servDataSource"); AuthorizationDAO authorizationDAO = new AuthorizationDAO(); authorizationDAO.setDataSource(dataSource); - String username = "admin"; // Admin user usually exists in test data - - // 1. Setup: Add some authorizations + String username = "admin"; + + // 1. Setup: Add multiple authorizations Group freeGroup = new Group(); freeGroup.setName("free"); Role editorRole = new Role(); editorRole.setName("editor"); Authorization auth1 = new Authorization(freeGroup, editorRole); - + Group coachGroup = new Group(); coachGroup.setName("coach"); Role pageManagerRole = new Role(); pageManagerRole.setName("pageManager"); Authorization auth2 = new Authorization(coachGroup, pageManagerRole); - authorizationDAO.addUserAuthorizations(username, Arrays.asList(auth1, auth2)); + Group customersGroup = new Group(); + customersGroup.setName("customers"); + Role supervisorRole = new Role(); + supervisorRole.setName("supervisor"); + Authorization auth3 = new Authorization(customersGroup, supervisorRole); - Map groups = Map.of("free", freeGroup, "coach", coachGroup); - Map roles = Map.of("editor", editorRole, "pageManager", pageManagerRole); + authorizationDAO.addUserAuthorizations(username, Arrays.asList(auth1, auth2, auth3)); + + Map groups = Map.of( + "free", freeGroup, + "coach", coachGroup, + "customers", customersGroup + ); + Map roles = Map.of( + "editor", editorRole, + "pageManager", pageManagerRole, + "supervisor", supervisorRole + ); List authorizations = authorizationDAO.getUserAuthorizations(username, groups, roles); assertTrue(containsAuth(authorizations, "free", "editor")); assertTrue(containsAuth(authorizations, "coach", "pageManager")); + assertTrue(containsAuth(authorizations, "customers", "supervisor")); + + // 2. Test deleteUserAuthorizationByGroupAndRole with specific groups and roles + authorizationDAO.deleteUserAuthorizationByGroupAndRole( + username, + Arrays.asList("free", "coach"), + Arrays.asList("editor") + ); - // 2. Test deleteUserRoles - authorizationDAO.deleteUserRoles(username, Arrays.asList("editor")); authorizations = authorizationDAO.getUserAuthorizations(username, groups, roles); assertFalse(containsAuth(authorizations, "free", "editor")); + assertFalse(containsAuth(authorizations, "coach", "pageManager")); + assertTrue(containsAuth(authorizations, "customers", "supervisor")); + } + + @Test + void testDeleteUserAuthorizationByGroupAndRoleWithOnlyGroups() throws Throwable { + DataSource dataSource = (DataSource) this.getApplicationContext().getBean("servDataSource"); + AuthorizationDAO authorizationDAO = new AuthorizationDAO(); + authorizationDAO.setDataSource(dataSource); + + String username = "admin"; + + // Setup + Group freeGroup = new Group(); + freeGroup.setName("free"); + Role editorRole = new Role(); + editorRole.setName("editor"); + Authorization auth1 = new Authorization(freeGroup, editorRole); + + Group coachGroup = new Group(); + coachGroup.setName("coach"); + Role pageManagerRole = new Role(); + pageManagerRole.setName("pageManager"); + Authorization auth2 = new Authorization(coachGroup, pageManagerRole); + + authorizationDAO.addUserAuthorizations(username, Arrays.asList(auth1, auth2)); + + Map groups = Map.of("free", freeGroup, "coach", coachGroup); + Map roles = Map.of("editor", editorRole, "pageManager", pageManagerRole); + + // Delete by groups only + authorizationDAO.deleteUserAuthorizationByGroupAndRole( + username, + Arrays.asList("free"), + null + ); + + List authorizations = authorizationDAO.getUserAuthorizations(username, groups, roles); + assertFalse(containsAuth(authorizations, "free", "editor")); assertTrue(containsAuth(authorizations, "coach", "pageManager")); + } - // 3. Test deleteUserGroups - authorizationDAO.deleteUserGroups(username, Arrays.asList("coach")); - authorizations = authorizationDAO.getUserAuthorizations(username, groups, roles); - assertFalse(containsAuth(authorizations, "coach", "pageManager")); + @Test + void testDeleteUserAuthorizationByGroupAndRoleWithOnlyRoles() throws Throwable { + DataSource dataSource = (DataSource) this.getApplicationContext().getBean("servDataSource"); + AuthorizationDAO authorizationDAO = new AuthorizationDAO(); + authorizationDAO.setDataSource(dataSource); + + String username = "admin"; + + // Setup + Group freeGroup = new Group(); + freeGroup.setName("free"); + Role editorRole = new Role(); + editorRole.setName("editor"); + Authorization auth1 = new Authorization(freeGroup, editorRole); + + Group coachGroup = new Group(); + coachGroup.setName("coach"); + Role pageManagerRole = new Role(); + pageManagerRole.setName("pageManager"); + Authorization auth2 = new Authorization(coachGroup, pageManagerRole); + + authorizationDAO.addUserAuthorizations(username, Arrays.asList(auth1, auth2)); + + Map groups = Map.of("free", freeGroup, "coach", coachGroup); + Map roles = Map.of("editor", editorRole, "pageManager", pageManagerRole); + + // Delete by roles only + authorizationDAO.deleteUserAuthorizationByGroupAndRole( + username, + null, + Arrays.asList("editor") + ); + + List authorizations = authorizationDAO.getUserAuthorizations(username, groups, roles); + assertFalse(containsAuth(authorizations, "free", "editor")); + assertTrue(containsAuth(authorizations, "coach", "pageManager")); } private boolean containsAuth(List authorizations, String group, String role) { return authorizations.stream() - .anyMatch(a -> a.getGroup() != null && a.getGroup().getName().equals(group) + .anyMatch(a -> a.getGroup() != null && a.getGroup().getName().equals(group) && a.getRole() != null && a.getRole().getName().equals(role)); } } 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 b1c0d3418..1b83c1324 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 @@ -18,9 +18,11 @@ import com.fasterxml.jackson.dataformat.xml.XmlMapper; import com.google.common.collect.Sets; import java.util.ArrayList; +import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Optional; import java.util.Set; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReadWriteLock; @@ -86,6 +88,8 @@ public KeycloakAuthorizationManager(final KeycloakConfiguration configuration, private transient List ignore; private transient List roles; private transient List groups; + private transient Boolean enabled; + private transient PersistKind persist; @Override public void init() throws Exception { @@ -113,9 +117,16 @@ public void init() throws Exception { log.debug("{} dynamic auth mapping found, {} profileMappings", dynConf.mapping.size(), profileMappings.size()); } - ignore = dynConf.ignore; - roles = dynConf.roles; - groups = dynConf.groups; + ignore = Optional.ofNullable(dynConf.exclusions) + .orElse(List.of()); + roles = Optional.ofNullable(dynConf.roles) + .orElseGet(List::of); + groups = Optional.ofNullable(dynConf.groups) + .orElse(List.of()); + enabled = Optional.ofNullable(dynConf.enabled) + .orElse(false); + persist = Optional.ofNullable(dynConf.persist) + .orElse(PersistKind.FULL); } } if (profileMappings != null) { @@ -125,6 +136,10 @@ public void init() throws Exception { jwtMappings.forEach(m -> log.debug("jwt mapping active: {}", m.toString())); } } catch (Exception e) { + // defaults + enabled = false; + roles = new ArrayList<>(); + groups = new ArrayList<>(); log.error("Error initializing KeycloakAuthorizationManager", e); throw e; } finally { @@ -168,8 +183,9 @@ private boolean isValid(DynamicMappingElement elem) { return true; } - public synchronized void processNewUser(final UserDetails user, final String token, final boolean decode) { + public void processNewUser(final UserDetails user, final String token, final boolean decode) { processNewUser(user); + if (!enabled) return; readLock.lock(); try { // Authorizations coming from dynamic mapping (that is, external sources) @@ -187,76 +203,7 @@ public synchronized void processNewUser(final UserDetails user, final String tok && !profileMappings.isEmpty()) { dynamicAuthorizations.addAll(processProfileAttributes((KeycloakUser) user)); } - // se l'autorizzazione dinamica non è già assegnata all'utente allora va aggiunta - List toAdd = dynamicAuthorizations - .stream() - .filter(a -> { - final String groupName = a.getGroup() != null ? a.getGroup().getName() : null; - final String roleName = a.getRole() != null ? a.getRole().getName() : null; - - assert user instanceof KeycloakUser; - return !isAlreadyAssigned((KeycloakUser) user, roleName, groupName); - }) - .collect(Collectors.toList()); - // list of the _managed_ authorizations currently assigned to the user - List existingAuths = user.getAuthorizations() - .stream() - .filter(a -> (a.getGroup() != null && groups.contains(a.getRole().getName()) - || (a.getRole() != null && roles.contains(a.getRole().getName()))) - ) - .collect(Collectors.toList()); - // se l'autorizzazione esistente non è contenuta nelle dynamic auths allora va cancellata - List toDelete = existingAuths - .stream() - .filter(a -> { - return dynamicAuthorizations.stream() - .noneMatch(d -> d.equals(a)); - }) - .collect(Collectors.toList()); - // finally - for (Authorization authorization : toAdd) { - persistAuthIfMissing((KeycloakUser) user, authorization); - - user.addAuthorization(authorization); - } -// for (Authorization authorization: toDelete) { -// List rolesToDelete = new ArrayList<>(); -// List groupsToDelete = new ArrayList<>(); -// -// if (authorization.getRole() != null) { -// rolesToDelete.add(authorization.getRole().getName()); -// } -// if (authorization.getGroup() != null) { -// groupsToDelete.add(authorization.getGroup().getName()); -// } -// -// } - - System.out.println("-------------------\n"); - dynamicAuthorizations.forEach(n-> { - final String groupName = n.getGroup() != null ? n.getGroup().getName() : null; - final String roleName = n.getRole() != null ? n.getRole().getName() : null; - - System.out.println("JWT " + user.getUsername() + " role " + roleName + " group " + groupName); - }); - existingAuths.forEach(n-> { - final String groupName = n.getGroup() != null ? n.getGroup().getName() : null; - final String roleName = n.getRole() != null ? n.getRole().getName() : null; - - System.out.println("USER " + user.getUsername() + " role " + roleName + " group " + groupName); - }); - toAdd.forEach(n-> { - final String groupName = n.getGroup() != null ? n.getGroup().getName() : null; - final String roleName = n.getRole() != null ? n.getRole().getName() : null; - - System.out.println("ADD " + user.getUsername() + " role " + roleName + " group " + groupName); - }); - toDelete.forEach(d-> { - final String groupName = d.getGroup() != null ? d.getGroup().getName() : null; - final String roleName = d.getRole() != null ? d.getRole().getName() : null; - - System.out.println("DELETE " + user.getUsername() + " role " + roleName + " group " + groupName); - }); + syncAuthorizations(user, dynamicAuthorizations); } catch (EntException e) { throw new RuntimeException(e); } finally { @@ -264,13 +211,127 @@ public synchronized void processNewUser(final UserDetails user, final String tok } } - public void cleanupManagedAuthorizations(final String username) throws EntException { - if (roles != null && !roles.isEmpty()) { - authorizationManager.deleteUserRoles(username, roles); + private void syncAuthorizations(final UserDetails user, final List dynamicAuthorizations) throws EntException { + // se l'autorizzazione dinamica non è già assegnata all'utente allora va aggiunta + List toAdd = dynamicAuthorizations + .stream() + .filter(a -> { + final String groupName = a.getGroup() != null ? a.getGroup().getName() : null; + final String roleName = a.getRole() != null ? a.getRole().getName() : null; + + assert user instanceof KeycloakUser; + return !isAlreadyAssigned((KeycloakUser) user, roleName, groupName); + }) + .collect(Collectors.toList()); + // list of the _managed_ authorizations currently assigned to the user + List existingAuths = Optional.ofNullable(user.getAuthorizations()) + .orElse(List.of()) + .stream() + .filter(a -> (a.getGroup() != null && groups.contains(a.getRole().getName()) + || (a.getRole() != null && roles.contains(a.getRole().getName()))) + ) + .collect(Collectors.toList()); + // se l'autorizzazione esistente non è contenuta nelle dynamic auths allora va cancellata + List toDelete = existingAuths + .stream() + .filter(a -> { + return dynamicAuthorizations.stream() + .noneMatch(d -> d.equals(a)); + }) + .collect(Collectors.toList()); + // finally + sillyDebug(user, dynamicAuthorizations, existingAuths, toAdd, toDelete); // DO NOT TEST, IGNORE + persistAuthorizations(user, toAdd, toDelete); + } + + private void persistAuthorizations(UserDetails user, List toAdd, List toDelete) + throws EntException { + // finally + for (Authorization authorization : toAdd) { + + if (persist == PersistKind.FULL) { + try { + authorizationManager.addUserAuthorization(user.getUsername(), authorization); + } catch (Exception e) { + log.debug("Failed to persist authorization for user {}: {}", user.getUsername(), e.getMessage()); + } + } + user.addAuthorization(authorization); + } + List index = new ArrayList<>(); + for (Authorization authorization: toDelete) { + List rolesToDelete = new ArrayList<>(); + List groupsToDelete = new ArrayList<>(); + + if (authorization.getRole() != null) { + rolesToDelete.add(authorization.getRole().getName()); + } + if (authorization.getGroup() != null) { + groupsToDelete.add(authorization.getGroup().getName()); + } + if (persist == PersistKind.FULL) { + authorizationManager.deleteUserAuthorizationByGroupAndRole(user.getUsername(), groupsToDelete, rolesToDelete); + } + index.add(indexOfAuthorization(user, authorization)); + } + index.sort(Comparator.reverseOrder()); + // sync permissions without reloading user auths + if (!index.isEmpty()) { + index.stream() + .filter(idx -> idx >= 0) + .forEach(idx -> user.getAuthorizations().remove(idx)); + } + } + + public static int indexOfAuthorization(UserDetails user, Authorization target) { + + if (user == null || target == null) { + return -1; } - if (groups != null && !groups.isEmpty()) { - authorizationManager.deleteUserGroups(username, groups); + + List authorizations = user.getAuthorizations(); + if (authorizations == null || authorizations.isEmpty()) { + return -1; } + + for (int i = 0; i < authorizations.size(); i++) { + Authorization current = authorizations.get(i); + + if (current.equals(target)) { + return i; + } + } + + return -1; + } + + private static void sillyDebug(UserDetails user, List dynamicAuthorizations, + List existingAuths, List toAdd, List toDelete) { + System.out.println("-------------------\n"); + dynamicAuthorizations.forEach(n-> { + final String groupName = n.getGroup() != null ? n.getGroup().getName() : null; + final String roleName = n.getRole() != null ? n.getRole().getName() : null; + + System.out.println("INCOMING " + user.getUsername() + " role " + roleName + " group " + groupName); + }); + existingAuths.forEach(n-> { + final String groupName = n.getGroup() != null ? n.getGroup().getName() : null; + final String roleName = n.getRole() != null ? n.getRole().getName() : null; + + System.out.println("USER " + user.getUsername() + " role " + roleName + " group " + groupName); + }); + toAdd.forEach(n-> { + final String groupName = n.getGroup() != null ? n.getGroup().getName() : null; + final String roleName = n.getRole() != null ? n.getRole().getName() : null; + + System.out.println("ADD " + user.getUsername() + " role " + roleName + " group " + groupName); + }); + toDelete.forEach(d-> { + final String groupName = d.getGroup() != null ? d.getGroup().getName() : null; + final String roleName = d.getRole() != null ? d.getRole().getName() : null; + + System.out.println("DELETE " + user.getUsername() + " role " + roleName + " group " + groupName); + }); } /** @@ -520,23 +581,33 @@ private boolean isIgnored(String name) { private Authorization finalizeAssociation(KeycloakUser user, DynamicMappingElement elem, String roleName, String groupName, boolean createRoleIfMissing) { + // is ignored? if (isIgnored(roleName) || isIgnored(groupName)) { - log.info("Role {} or Group {} is in the ignore list. Skipping assignment for user {}", roleName, groupName, user.getUsername()); + log.info("Role {} or Group {} is in the exclusions list. Skipping assignment for user {}", roleName, groupName, user.getUsername()); + return null; + } + if (StringUtils.isNotBlank(roleName) &&!roles.contains(roleName)) { + System.out.println(">>> IGNORO RUOLO " + roleName); + log.info("Role {} is not managed. Skipping assignment for user {}", roleName, user.getUsername()); + return null; + } + if (StringUtils.isNotBlank(groupName) && !groups.contains(groupName)) { + System.out.println(">>> IGNORO GRUPPO " + groupName); + log.info("Group {} is not managed. Skipping assignment for user {}", groupName, user.getUsername()); return null; } - return createAuthorization(elem, roleName, groupName, createRoleIfMissing); } private Authorization createAuthorization(DynamicMappingElement elem, String roleName, String groupName, boolean createRoleIfMissing) { - if (shouldPersistAuthorization(elem)) { + if (shouldPersistAuthorization()) { return createPersistedAuthorization(roleName, groupName); } return createTransientAuthorization(roleName, groupName, createRoleIfMissing); } - private boolean shouldPersistAuthorization(DynamicMappingElement elem) { - return elem.persist == PersistKind.AUTH || elem.persist == PersistKind.FULL; + private boolean shouldPersistAuthorization() { + return this.persist == PersistKind.AUTH || this.persist == PersistKind.FULL; } private Authorization createPersistedAuthorization(String roleName, String groupName) { @@ -611,46 +682,4 @@ private List finalizeGroupAssociation(KeycloakUser user, DynamicM return result; } - - - /** - * To avoid creating duplicate records, we check if the authorization already exists. - * In a replicated environment or under high concurrency, there is still the possibility - * to attempt to create multiple, identical associations. This is handled by a database - * unique constraint and a try-catch block. - * @param user the user being processed - * @param auth the authorization to persist - * @throws EntException in case of errors - */ - private void persistAuthIfMissing(KeycloakUser user, Authorization auth) throws EntException { - final String username = user.getUsername(); - final List existing = authorizationManager.getUserAuthorizations(username); - - final String targetGroupName = (null != auth.getGroup()) ? auth.getGroup().getName() : null; - final String targetRoleName = (null != auth.getRole()) ? auth.getRole().getName() : null; - - boolean alreadyExists = existing.stream().anyMatch(a -> { - String existingGroupName = (null != a.getGroup()) ? a.getGroup().getName() : null; - String existingRoleName = (null != a.getRole()) ? a.getRole().getName() : null; - - return Objects.equals(existingGroupName, targetGroupName) && - Objects.equals(existingRoleName, targetRoleName); - }); - - if (!alreadyExists) { - log.debug("Persisting new authorization for user '{}': group={}, role={}", - username, targetGroupName, targetRoleName); - try { - authorizationManager.addUserAuthorization(username, auth); - } catch (EntException e) { - log.debug("Error persisting authorization for user '{}': group={}, role={} " - + "(it might have been already added by another process).", - username, targetGroupName, targetRoleName); - } - } else { - log.debug("Authorization already exists for user '{}': group={}, role={}. Skipping persistence.", - username, targetGroupName, targetRoleName); - } - } - } diff --git a/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/DynamicMapping.java b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/DynamicMapping.java index ea6a94ff7..832674904 100644 --- a/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/DynamicMapping.java +++ b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/DynamicMapping.java @@ -5,15 +5,15 @@ import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement; import java.util.List; -@JacksonXmlRootElement(localName = "dynamicmapping") +@JacksonXmlRootElement(localName = "dynamicMapping") @JsonInclude(JsonInclude.Include.NON_EMPTY) public class DynamicMapping { - @JacksonXmlElementWrapper(useWrapping = false) + @JacksonXmlElementWrapper(localName = "mappings") public List mapping; - @JacksonXmlElementWrapper(useWrapping = false) - public List ignore; + @JacksonXmlElementWrapper(localName = "exclusions") + public List exclusions; @JacksonXmlElementWrapper(localName = "roles") public List roles; @@ -21,4 +21,6 @@ public class DynamicMapping { @JacksonXmlElementWrapper(localName = "groups") public List groups; + public Boolean enabled; + public PersistKind persist; } diff --git a/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/DynamicMappingElement.java b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/DynamicMappingElement.java index e4890cb21..e511dd009 100644 --- a/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/DynamicMappingElement.java +++ b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/DynamicMappingElement.java @@ -6,7 +6,6 @@ public class DynamicMappingElement { public boolean enabled; public String attribute; public DynamicMappingKind kind; - public PersistKind persist; public String separator; // FOR ROLEGROUP and GROUPROLECLAIM ONLY public String path; // FOR *CLAIM ONLY diff --git a/keycloak-plugin/src/main/resources/liquibase/entando-keycloak-auth/port/clob/production/sysconfig_kc.xml b/keycloak-plugin/src/main/resources/liquibase/entando-keycloak-auth/port/clob/production/sysconfig_kc.xml index 5197ea8df..50e9746f6 100644 --- a/keycloak-plugin/src/main/resources/liquibase/entando-keycloak-auth/port/clob/production/sysconfig_kc.xml +++ b/keycloak-plugin/src/main/resources/liquibase/entando-keycloak-auth/port/clob/production/sysconfig_kc.xml @@ -1,43 +1,52 @@ - - - false - groups - GROUPCLAIM - none - - - false - realm_access.roles - ROLECLAIM - none - - - false - realm_access.roles - ROLEGROUPCLAIM - _SEP_ - none - - - false - AD_ROLE - ROLE - none - - - false - AD_GROUP - GROUP - none - - - false - AD_GROUPROLE - ROLEGROUP - _r_ - none - - ignore_keycloak_group - ignore_keycloak_role - + + true + FULL + + + true + groups + GROUPCLAIM + + + true + realm_access.roles + ROLECLAIM + + + false + realm_access.roles + ROLEGROUPCLAIM + _SEP_ + + + false + AD_ROLE + ROLE + + + false + AD_GROUP + GROUP + + + false + AD_GROUPROLE + ROLEGROUP + _r_ + + + + default-roles-entando-development + offline_access + uma_authorization + + + imported_role + imported_role2 + + + imported_group + imported_group2 + + diff --git a/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerComplexTest.java b/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerComplexTest.java new file mode 100644 index 000000000..b5d1048e6 --- /dev/null +++ b/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerComplexTest.java @@ -0,0 +1,527 @@ +package org.entando.entando.keycloak.services; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +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 java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.entando.entando.keycloak.services.oidc.OidcMappingService; +import org.entando.entando.keycloak.services.oidc.model.KeycloakUser; +import org.entando.entando.keycloak.services.oidc.model.UserRepresentation; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.internal.matchers.Any; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.ProviderManager; + +@ExtendWith(MockitoExtension.class) +class KeycloakAuthorizationManagerComplexTest { + + @Mock + private ProviderManager providerManager; + + @Mock + private KeycloakConfiguration configuration; + + @Mock + private AuthorizationManager authorizationManager; + + @Mock + private GroupManager groupManager; + + @Mock + private RoleManager roleManager; + + @Mock + private BaseConfigManager configManager; + private OidcMappingService oidcMappingService = new OidcMappingService(); + + private KeycloakAuthorizationManager manager; + + @BeforeEach + public void setUp() { + manager = new KeycloakAuthorizationManager(configuration, authorizationManager, groupManager, roleManager, configManager, oidcMappingService); + lenient().when(configuration.getDefaultAuthorizations()).thenReturn(""); + } + + private void setMappingConfig(String xml) throws Exception { + when(configManager.getConfigItem("dynamicAuthMapping")).thenReturn(xml); + manager.init(); + } + + @Test + void testKindGroupWithPersistNone() throws Exception { + String xml = "" + + " true" + + " none" + + " " + + " " + + " true" + + " kc_groups" + + " group" + + " " + + " " + + " " + + " group1" + + " group2" + + " " + + ""; + setMappingConfig(xml); + + KeycloakUser user = createKeycloakUser("test-user", "kc_groups", List.of("group1", "group2")); + + manager.processNewUser(user, null, false); + + assertThat(user.getAuthorizations()).hasSize(2); + assertThat(user.getAuthorizations()).extracting(a -> a.getGroup().getName()).containsExactlyInAnyOrder("group1", "group2"); + assertThat(user.getAuthorizations()).allMatch(a -> a.getRole() == null); + + verify(authorizationManager, never()).addUserAuthorization(anyString(), any(Authorization.class)); + } + + @Test + void testKindRoleWithPersistAuth() throws Exception { + String xml = "" + + " true" + + " auth" + + " " + + " " + + " true" + + " kc_roles" + + " role" + + " " + + " " + + " " + + " role1" + + " role2" + + " " + + ""; + setMappingConfig(xml); + + KeycloakUser user = createKeycloakUser("test-user", "kc_roles", List.of("role1", "role2")); + when(roleManager.getRole("role1")).thenReturn(null); + when(roleManager.getRole("role2")).thenReturn(null); + + manager.processNewUser(user, null, false); + + assertThat(user.getAuthorizations()).hasSize(2); + assertThat(user.getAuthorizations()).extracting(a -> a.getRole().getName()).containsExactlyInAnyOrder("role1", "role2"); + + // PersistKind.AUTH creates roles/groups in DB but does not call addUserAuthorization + verify(roleManager, atLeastOnce()).getRole(anyString()); + // verify(roleManager).addRole(any(Role.class)); // Not sure if addRole is called if role is missing in transient mode + } + + @Test + void testKindRoleGroupWithPersistFull() throws Exception { + String xml = "" + + " true" + + " full" + + " " + + " " + + " true" + + " kc_rolegroups" + + " rolegroup" + + " _SEP_" + + " " + + " " + + " " + + " role1" + + " role2" + + " " + + " " + + " group1" + + " group2" + + " " + + ""; + setMappingConfig(xml); + + KeycloakUser user = createKeycloakUser("test-user", "kc_rolegroups", List.of("role1_SEP_group1", "role2_SEP_group2")); + + Group group1 = new Group(); group1.setName("group1"); + Group group2 = new Group(); group2.setName("group2"); + Role role1 = new Role(); role1.setName("role1"); + Role role2 = new Role(); role2.setName("role2"); + + when(groupManager.getGroup("group1")).thenReturn(group1); + when(groupManager.getGroup("group2")).thenReturn(group2); + when(roleManager.getRole("role1")).thenReturn(role1); + when(roleManager.getRole("role2")).thenReturn(role2); + + manager.processNewUser(user, null, false); + + assertThat(user.getAuthorizations()).hasSize(2); + verify(authorizationManager, atLeastOnce()).addUserAuthorization(anyString(), any(Authorization.class)); + } + + @Test + void testJwtClaimMapping() throws Exception { + String xml = "" + + " true" + + " none" + + " " + + " " + + " true" + + " resource_access.client1.roles" + + " roleclaim" + + " " + + " " + + " " + + " jwt-role1" + + " " + + ""; + setMappingConfig(xml); + + String token = "header." + java.util.Base64.getUrlEncoder().encodeToString("{\"resource_access\":{\"client1\":{\"roles\":[\"jwt-role1\"]}}}".getBytes()) + ".signature"; + KeycloakUser user = createKeycloakUser("test-user", null, null); + + manager.processNewUser(user, token, true); + + assertThat(user.getAuthorizations()).hasSize(1); + assertThat(user.getAuthorizations().get(0).getRole().getName()).isEqualTo("jwt-role1"); + } + + @Test + void testGroupClaimMapping() throws Exception { + String xml = "" + + " true" + + " none" + + " " + + " " + + " true" + + " custom_groups" + + " groupclaim" + + " " + + " " + + " " + + " jwt-group1" + + " jwt-group2" + + " " + + ""; + setMappingConfig(xml); + + String token = "header." + java.util.Base64.getUrlEncoder().encodeToString("{\"custom_groups\":[\"jwt-group1\", \"jwt-group2\"]}".getBytes()) + ".signature"; + KeycloakUser user = createKeycloakUser("test-user", null, null); + + manager.processNewUser(user, token, true); + + assertThat(user.getAuthorizations()).hasSize(2); + assertThat(user.getAuthorizations()).extracting(a -> a.getGroup().getName()).containsExactlyInAnyOrder("jwt-group1", "jwt-group2"); + } + + @Test + void testRoleGroupClaimMapping() throws Exception { + String xml = "" + + " true" + + " none" + + " " + + " " + + " true" + + " complex_auth" + + " rolegroupclaim" + + " :" + + " " + + " " + + " " + + " roleA" + + " roleB" + + " " + + " " + + " groupB" + + " groupA" + + " " + + ""; + setMappingConfig(xml); + + String token = "header." + java.util.Base64.getUrlEncoder().encodeToString("{\"complex_auth\":[\"roleA:groupA\", \"roleB:groupB\"]}".getBytes()) + ".signature"; + KeycloakUser user = createKeycloakUser("test-user", null, null); + + manager.processNewUser(user, token, true); + + assertThat(user.getAuthorizations()).hasSize(2); + assertThat(user.getAuthorizations()).anyMatch(a -> a.getRole().getName().equals("roleA") && a.getGroup().getName().equals("groupA")); + assertThat(user.getAuthorizations()).anyMatch(a -> a.getRole().getName().equals("roleB") && a.getGroup().getName().equals("groupB")); + } + + @Test + void testExclusions() throws Exception { + String xml = "" + + " true" + + " none" + + " " + + " " + + " true" + + " kc_roles" + + " role" + + " " + + " " + + " " + + " ignored-role" + + " " + + " " + + " role1" + + " " + + ""; + setMappingConfig(xml); + + KeycloakUser user = createKeycloakUser("test-user", "kc_roles", List.of("role1", "ignored-role")); + + manager.processNewUser(user, null, false); + + assertThat(user.getAuthorizations()).hasSize(1); + assertThat(user.getAuthorizations().get(0).getRole().getName()).isEqualTo("role1"); + } + + @Test + void testPersistAuthorizations() throws Exception { + String xml = "" + + " FULL" + + " true" + + " " + + " " + + " true" + + " kc_roles" + + " role" + + " " + + " " + + " " + + " role-managed" + + " role1" + + " " + + ""; + setMappingConfig(xml); + + KeycloakUser user = createKeycloakUser("test-user", "kc_roles", List.of("role1")); + + // Pre-existing managed role that should be deleted if not in dynamic auths + Role managedRole = new Role(); managedRole.setName("role-managed"); + Authorization existingAuth = new Authorization(null, managedRole); + + // In this test, we use a mutable list for authorizations to allow removal + List auths = new ArrayList<>(); + auths.add(existingAuth); + user.setAuthorizations(auths); + + // Mock role1 to have a name + Role role1 = new Role(); role1.setName("role1"); +// when(roleManager.getRole("role1")).thenReturn(role1); + + manager.processNewUser(user, null, false); + + // role-managed should be deleted because its role name is in the "roles" managed list + verify(authorizationManager, times(1)).deleteUserAuthorizationByGroupAndRole(eq("test-user"), anyList(), anyList()); + } + + @Test + void testPersistAuthorizationsMultiple() throws Exception { + String xml = "" + + " true" + + " none" + + " " + + " " + + " true" + + " kc_roles" + + " role" + + " " + + " " + + " " + + " role2" + + " role1" + + " " + + ""; + setMappingConfig(xml); + + KeycloakUser user = createKeycloakUser("test-user", "kc_roles", List.of("role1")); + + List auths = new ArrayList<>(); + Role m1 = new Role(); m1.setName("managed1"); + auths.add(new Authorization(null, m1)); + Role m2 = new Role(); m2.setName("managed2"); + auths.add(new Authorization(null, m2)); + user.setAuthorizations(auths); + + Role role1 = new Role(); role1.setName("role1"); + when(roleManager.getRole("role1")).thenReturn(role1); + + manager.processNewUser(user, null, false); + + assertThat(user.getAuthorizations()) + .extracting(a -> a.getRole().getName()).containsExactly("managed1", "managed2", "role1"); + } + + private KeycloakUser createKeycloakUser(String username, String attrName, List attrValues) { + KeycloakUser user = new KeycloakUser(); + user.setUsername(username); + user.setAuthorizations(new ArrayList<>()); + + UserRepresentation ur = new UserRepresentation(); + ur.setUsername(username); + Map attributes = new HashMap<>(); + if (attrName != null) { + attributes.put(attrName, attrValues); + } + ur.setAttributes(attributes); + user.setUserRepresentation(ur); + + return user; + } + + + private static final String JWT_NO_ROLE = "{" + + " \"header\" : {" + + " \"alg\" : \"RS256\"," + + " \"typ\" : \"JWT\"," + + " \"kid\" : \"l09Wlf_NY_dmMORYBjkr7deFVGVJ5TRLHW1p7DIT1ds\"" + + " }," + + " \"payload\" : {" + + " \"exp\" : 1768319443," + + " \"iat\" : 1768319143," + + " \"auth_time\" : 1768319142," + + " \"jti\" : \"e64ed1da-aa8c-488f-be10-09e0c2f580c3\"," + + " \"iss\" : \"https://localhost:8080/auth/realms/entando\"," + + " \"aud\" : [ \"sim730\", \"account\" ]," + + " \"sub\" : \"5e7213c6-ad81-4094-bb24-fead709b05af\"," + + " \"typ\" : \"Bearer\"," + + " \"azp\" : \"entando-web\"," + + " \"nonce\" : \"6a9f89c2-c904-4e9e-80cb-e8c1ccddd1e0\"," + + " \"session_state\" : \"0503e261-d522-41b6-8096-1debdd2c86e7\"," + + " \"acr\" : \"1\"," + + " \"allowed-origins\" : [ \"https://localhost:8080\", \"*\" ]," + + " \"realm_access\" : {" + + " \"roles\" : [ \"offline_access\", \"uma_authorization\", \"default-roles-entando\" ]" + + " }," + + " \"resource_access\" : {" + + " \"aclient\" : {" + + " \"roles\" : [ \"generico\" ]" + + " }," + + " \"account\" : {" + + " \"roles\" : [ \"manage-account\", \"manage-account-links\", \"view-profile\" ]" + + " }" + + " }," + + " \"scope\" : \"openid profile email\"," + + " \"sid\" : \"0503e261-d522-41b6-8096-1debdd2c86e7\"," + + " \"email_verified\" : false," + + " \"name\" : \"User lastname\"," + + " \"preferred_username\" : \"user@email.it\"," + + " \"given_name\" : \"User\"," + + " \"family_name\" : \"lastname\"," + + " \"email\" : \"user@email.it\"," + + " \"miei_ruoli_custom\" : [ \"offline_access\", \"uma_authorization\", \"default-roles-entando\" ]" + + " }," + + " \"signature\" : \"dLENSPEPw\"" + + "}"; + + private static final String JWT = "{" + + " \"exp\" : 1768319443," + + " \"iat\" : 1768319143," + + " \"auth_time\" : 1768319142," + + " \"jti\" : \"e64ed1da-aa8c-488f-be10-09e0c2f580c3\"," + + " \"iss\" : \"https://localhost:8080/auth/realms/entando\"," + + " \"aud\" : [ \"sim730\", \"account\" ]," + + " \"sub\" : \"5e7213c6-ad81-4094-bb24-fead709b05af\"," + + " \"typ\" : \"Bearer\"," + + " \"azp\" : \"entando-web\"," + + " \"nonce\" : \"6a9f89c2-c904-4e9e-80cb-e8c1ccddd1e0\"," + + " \"session_state\" : \"0503e261-d522-41b6-8096-1debdd2c86e7\"," + + " \"acr\" : \"1\"," + + " \"allowed-origins\" : [ \"https://localhost:8080\", \"*\" ]," + + " \"realm_access\" : {" + + " \"roles\" : [ \"offline_access\", \"uma_authorization\", \"default-roles-entando\", \"generico\" ]" + + " }," + + " \"resource_access\" : {" + + " \"sim730\" : {" + + " \"roles\" : [ \"generico\" ]" + + " }," + + " \"account\" : {" + + " \"roles\" : [ \"manage-account\", \"manage-account-links\", \"view-profile\" ]" + + " }" + + " }," + + " \"scope\" : \"openid profile email\"," + + " \"sid\" : \"0503e261-d522-41b6-8096-1debdd2c86e7\"," + + " \"email_verified\" : false," + + " \"name\" : \"User lastname\"," + + " \"groups\": [" + + " \"Gruppo-Microsoft-Importato\", \"altro-gruppo\" " + + " ]," + + " \"preferred_username\" : \"user@email.it\"," + + " \"given_name\" : \"User\"," + + " \"family_name\" : \"lastname\"," + + " \"email\" : \"user@email.it\"," + + " \"miei_ruoli_custom\" : [ \"offline_access\", \"uma_authorization\", \"default-roles-entando\" ]" + + " }"; + + private static final String JWT_ROLEGROUP = "{" + + " \"exp\" : 1768319443," + + " \"iat\" : 1768319143," + + " \"auth_time\" : 1768319142," + + " \"jti\" : \"e64ed1da-aa8c-488f-be10-09e0c2f580c3\"," + + " \"iss\" : \"https://localhost:8080/auth/realms/entando\"," + + " \"aud\" : [ \"sim730\", \"account\" ]," + + " \"sub\" : \"5e7213c6-ad81-4094-bb24-fead709b05af\"," + + " \"typ\" : \"Bearer\"," + + " \"azp\" : \"entando-web\"," + + " \"nonce\" : \"6a9f89c2-c904-4e9e-80cb-e8c1ccddd1e0\"," + + " \"session_state\" : \"0503e261-d522-41b6-8096-1debdd2c86e7\"," + + " \"acr\" : \"1\"," + + " \"allowed-origins\" : [ \"https://localhost:8080\", \"*\" ]," + + " \"realm_access\" : {" + + " \"roles\" : [ \"role1_SEP_group1\", \"role2_SEP_group2\" ]" + + " }," + + " \"scope\" : \"openid profile email\"," + + " \"sid\" : \"0503e261-d522-41b6-8096-1debdd2c86e7\"," + + " \"email_verified\" : false," + + " \"name\" : \"User lastname\"," + + " \"preferred_username\" : \"user@email.it\"," + + " \"given_name\" : \"User\"," + + " \"family_name\" : \"lastname\"," + + " \"email\" : \"user@email.it\"" + + " }"; + + private static final String JWT_ROLEGROUP_EDGE = "{" + + " \"exp\" : 1768319443," + + " \"iat\" : 1768319143," + + " \"auth_time\" : 1768319142," + + " \"jti\" : \"e64ed1da-aa8c-488f-be10-09e0c2f580c3\"," + + " \"iss\" : \"https://localhost:8080/auth/realms/entando\"," + + " \"aud\" : [ \"sim730\", \"account\" ]," + + " \"sub\" : \"5e7213c6-ad81-4094-bb24-fead709b05af\"," + + " \"typ\" : \"Bearer\"," + + " \"azp\" : \"entando-web\"," + + " \"nonce\" : \"6a9f89c2-c904-4e9e-80cb-e8c1ccddd1e0\"," + + " \"session_state\" : \"0503e261-d522-41b6-8096-1debdd2c86e7\"," + + " \"acr\" : \"1\"," + + " \"allowed-origins\" : [ \"https://localhost:8080\", \"*\" ]," + + " \"realm_access\" : {" + + " \"roles\" : [ \"group1\", \"_SEP_group2\" ]" + + " }," + + " \"scope\" : \"openid profile email\"," + + " \"sid\" : \"0503e261-d522-41b6-8096-1debdd2c86e7\"," + + " \"email_verified\" : false," + + " \"name\" : \"User lastname\"," + + " \"preferred_username\" : \"user@email.it\"," + + " \"given_name\" : \"User\"," + + " \"family_name\" : \"lastname\"," + + " \"email\" : \"user@email.it\"" + + " }"; +} diff --git a/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java b/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java index d9c8a4294..2e1b955ce 100644 --- a/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java +++ b/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java @@ -25,6 +25,7 @@ import java.util.List; import java.util.Map; import org.entando.entando.ent.exception.EntException; +import org.entando.entando.keycloak.services.mapping.PersistKind; import org.entando.entando.keycloak.services.oidc.OidcMappingService; import org.entando.entando.keycloak.services.oidc.model.KeycloakUser; import org.entando.entando.keycloak.services.oidc.model.UserRepresentation; @@ -54,11 +55,13 @@ public void setUp() { } @Test - void testGroupCreation() throws EntException { + void testGroupCreation() throws Exception { when(configuration.getDefaultAuthorizations()).thenReturn("readers"); when(groupManager.getGroup(anyString())).thenReturn(null); when(userDetails.getAuthorizations()).thenReturn(new ArrayList<>()); + when(configManager.getConfigItem("dynamicAuthMapping")).thenReturn("true"); + manager.init(); manager.processNewUser(userDetails, null, false); final ArgumentCaptor groupCaptor = ArgumentCaptor.forClass(Group.class); @@ -82,12 +85,14 @@ void testGroupCreation() throws EntException { } @Test - void testGroupAndRoleCreation() throws EntException { + void testGroupAndRoleCreation() throws Exception { when(configuration.getDefaultAuthorizations()).thenReturn("readers:read-all"); when(groupManager.getGroup(anyString())).thenReturn(null); when(roleManager.getRole(anyString())).thenReturn(null); when(userDetails.getAuthorizations()).thenReturn(new ArrayList<>()); + when(configManager.getConfigItem("dynamicAuthMapping")).thenReturn("true"); + manager.init(); manager.processNewUser(userDetails, null, false); final ArgumentCaptor groupCaptor = ArgumentCaptor.forClass(Group.class); @@ -114,7 +119,24 @@ void testGroupAndRoleCreation() throws EntException { } @Test - void testVerification() { + void testVerification() throws Exception { + final String xmlEmptyGroupsRoles = "" + + "" + + " true" + + " FULL" + + " " + + " " + + " " + + " " + + ""; + + // 2. Mocking del configManager per restituire questo XML + when(configManager.getConfigItem("dynamicAuthMapping")).thenReturn(xmlEmptyGroupsRoles); + when(configuration.getDefaultAuthorizations()).thenReturn(null); + when(userDetails.getAuthorizations()).thenReturn(new ArrayList<>()); + + manager.init(); + final Authorization readers = authorization("readers", "read-all"); final Authorization writers = authorization("writers", "write-all"); @@ -126,6 +148,7 @@ void testVerification() { verify(roleManager, times(0)).getRole(anyString()); verify(groupManager, times(0)).getGroup(anyString()); verify(userDetails, times(0)).addAuthorization(any()); + verify(authorizationManager, never()).addUserAuthorization(anyString(), any()); } @Test @@ -167,11 +190,11 @@ void testDynamicConfigurationRoleOnLoginFromJwt() throws Exception { manager.processNewUser(userDetails, JWT, false); - verify(authorizationManager, times(4)).addUserAuthorization(eq("testuser"), authCaptor.capture()); + verify(authorizationManager, times(1)).addUserAuthorization(eq("testuser"), authCaptor.capture()); assertThat(authCaptor.getAllValues()) .extracting(a -> a.getRole().getName()) - .containsOnly("generico","offline_access", "uma_authorization", "default-roles-entando"); + .containsOnly("generico"); assertThat(authCaptor.getValue().getGroup()).isNull(); } @@ -190,11 +213,11 @@ void testDynamicConfigurationRoleOnLoginFromJwtNoPersist() throws Exception { manager.processNewUser(userDetails, JWT, false); verify(authorizationManager, never()).addUserAuthorization(eq("testuser"), any()); - verify(userDetails, times(4)).addAuthorization(authCaptor.capture()); + verify(userDetails, times(1)).addAuthorization(authCaptor.capture()); assertThat(authCaptor.getAllValues()) .extracting(a -> a.getRole().getName()) - .containsOnly("generico", "offline_access", "uma_authorization", "default-roles-entando"); + .containsOnly("generico"); assertThat(authCaptor.getValue().getGroup()).isNull(); } @@ -359,7 +382,6 @@ void testDynamicConfigurationGroupRoleOnLoginAlreadyPresent() throws Exception { role.setName("arole"); role.setDescription("arole"); - when(authorizationManager.getUserAuthorizations(anyString())).thenReturn(List.of(auth)); when(configuration.getDefaultAuthorizations()).thenReturn(null); when(configManager.getConfigItem(anyString())).thenReturn(XML_GROUP_ROLE_CONF); @@ -368,6 +390,7 @@ void testDynamicConfigurationGroupRoleOnLoginAlreadyPresent() throws Exception { when(userDetails.getUsername()).thenReturn("testuser"); when(userDetails.getUserRepresentation()).thenReturn(userRepresentation); + when(userDetails.getAuthorizations()).thenReturn(List.of(auth)); manager.init(); @@ -456,7 +479,6 @@ void testDynamicConfigurationGroupRoleOnLoginConflict() throws Exception { role.setName("arole"); role.setDescription("arole"); - when(authorizationManager.getUserAuthorizations(anyString())).thenReturn(List.of()); when(configuration.getDefaultAuthorizations()).thenReturn(null); when(configManager.getConfigItem(anyString())).thenReturn(XML_GROUP_ROLE_CONF); when(groupManager.getGroup(anyString())).thenReturn(group); @@ -474,7 +496,7 @@ void testDynamicConfigurationGroupRoleOnLoginConflict() throws Exception { manager.init(); - // This should not throw an exception because it's caught in persistAuthIfMissing + // This should not throw an exception because it's caught in syncAuthorizations manager.processNewUser(userDetails, JWT, true); verify(authorizationManager, times(1)).addUserAuthorization(eq("testuser"), any()); @@ -580,8 +602,8 @@ void testDynamicConfigurationWithIgnoredRoles() throws Exception { // JWT contains "generico", "offline_access", "uma_authorization", "default-roles-entando" // "generico" is ignored, so we expect only 3 calls - verify(authorizationManager, times(3)).addUserAuthorization(eq("testuser"), any()); - verify(authorizationManager, never()).addUserAuthorization(eq("testuser"), argThat(auth -> + verify(authorizationManager, times(1)).addUserAuthorization(eq("testuser"), any()); + verify(authorizationManager, times(1)).addUserAuthorization(eq("testuser"), argThat(auth -> auth.getRole() != null && "generico".equals(auth.getRole().getName()))); } @@ -605,20 +627,37 @@ void testDynamicConfigurationWithIgnoredGroups() throws Exception { @Test void testRoleFromProfileAndJwtWithPersistAuth() throws Exception { // Configurazione: un mapping per profilo (ROLE) e uno per JWT (ROLECLAIM), entrambi con persist=AUTH - String xmlConf = "" + String xmlConf = "" + + " AUTH" + + " true" + + "" + " " + " true" + " AD_ROLE" + " ROLE" - + " AUTH" + " " + " " + " true" + " realm_access.roles" + " ROLECLAIM" - + " AUTH" + " " - + ""; + + "" + + + " " + + " default-roles-entando-development" + + " offline_access" + + " uma_authorization" + + " " + + " " + + " generico" + + " role_from_profile" + + " " + + " " + + " imported_group" + + " imported_group2" + + " " + + + ""; when(configuration.getDefaultAuthorizations()).thenReturn(null); when(configManager.getConfigItem(anyString())).thenReturn(xmlConf); @@ -651,14 +690,22 @@ void testRoleFromProfileAndJwtWithPersistAuth() throws Exception { @Test void testAuthAssignmentWhenRoleGroupExistWithPersistAuth() throws Exception { - String xmlConf = "" + String xmlConf = "" + + " AUTH" + + " true" + + "" + " " + " true" + " AD_ROLE" + " ROLE" - + " AUTH" + " " - + ""; + + "" + + + " " + + " existing_role" + + " " + + + ""; when(configuration.getDefaultAuthorizations()).thenReturn(null); when(configManager.getConfigItem(anyString())).thenReturn(xmlConf); @@ -687,14 +734,20 @@ void testAuthAssignmentWhenRoleGroupExistWithPersistAuth() throws Exception { @Test void testAuthAssignmentWhenRoleExistsAndAddRoleFailsWithPersistAuth() throws Exception { - String xmlConf = "" + String xmlConf = "" + + " AUTH" + + " true" + + "" + " " + " true" + " AD_ROLE" + " ROLE" - + " AUTH" + " " - + ""; + + "" + + " " + + " conflict_role" + + " " + + ""; when(configuration.getDefaultAuthorizations()).thenReturn(null); when(configManager.getConfigItem(anyString())).thenReturn(xmlConf); @@ -727,95 +780,34 @@ void testAuthAssignmentWhenRoleExistsAndAddRoleFailsWithPersistAuth() throws Exc } - @Test - void testCleanupManagedAuthorizationsEmptyLists() throws Exception { - String xml = "" - + ""; - - when(configManager.getConfigItem("dynamicAuthMapping")).thenReturn(xml); - manager.init(); - - manager.processNewUser(userDetails, null, false); - - verify(authorizationManager, never()).deleteUserAuthorization(anyString(), anyString(), anyString()); - } - - @Test - void testCleanupManagedAuthorizationsNullLists() throws Exception { - // configManager.getConfigItem returns null or empty - when(configManager.getConfigItem("dynamicAuthMapping")).thenReturn(""); - manager.init(); - - List userAuths = new ArrayList<>(); - userAuths.add(authorization(null, "some-role")); - // No need to mock userDetails.getAuthorizations() if roles/groups are null/empty, - // but it doesn't hurt. - - manager.processNewUser(userDetails, null, false); - - verify(authorizationManager, never()).deleteUserAuthorization(anyString(), anyString(), anyString()); - } @Test void testCleanupManagedAuthorizationsWithRolesAndGroups() throws Exception { - String xml = "" + String xml = "" + + " true" + + " full" + " " - + " roleA" - + " roleB" + + " roleA" + + " roleB" + " " + " " - + " groupA" - + " groupB" + + " groupA" + + " groupB" + " " - + ""; + + ""; when(configManager.getConfigItem("dynamicAuthMapping")).thenReturn(xml); + when(userDetails.getUsername()).thenReturn("john"); - manager.init(); - - String username = "john"; - manager.cleanupManagedAuthorizations(username); - - verify(authorizationManager, times(1)).deleteUserRoles(eq(username), eq(List.of("roleA", "roleB"))); - verify(authorizationManager, times(1)).deleteUserGroups(eq(username), eq(List.of("groupA", "groupB"))); - } - - @Test - void testCleanupManagedAuthorizationsOnlyRoles() throws Exception { - String xml = "" - + " " - + " roleA" - + " " - + ""; - - when(configManager.getConfigItem("dynamicAuthMapping")).thenReturn(xml); - - manager.init(); - - String username = "jane"; - manager.cleanupManagedAuthorizations(username); - - verify(authorizationManager, times(1)).deleteUserRoles(eq(username), eq(List.of("roleA"))); - verify(authorizationManager, never()).deleteUserGroups(anyString(), any()); - } - - @Test - void testCleanupManagedAuthorizationsOnlyGroups() throws Exception { - String xml = "" - + " " - + " groupA" - + " " - + ""; - - when(configManager.getConfigItem("dynamicAuthMapping")).thenReturn(xml); + List existingAuths = new ArrayList<>(); + existingAuths.add(authorization("groupA", "roleA")); + when(userDetails.getAuthorizations()).thenReturn(existingAuths); manager.init(); + manager.processNewUser(userDetails, null, false); - String username = "mark"; - manager.cleanupManagedAuthorizations(username); - - verify(authorizationManager, times(1)).deleteUserGroups(eq(username), eq(List.of("groupA"))); - verify(authorizationManager, never()).deleteUserRoles(anyString(), any()); + // It should try to delete groupA/roleA because it's managed but not in current dynamic auths (which are empty) + verify(authorizationManager, times(1)).deleteUserAuthorizationByGroupAndRole(eq("john"), eq(List.of("groupA")), eq(List.of("roleA"))); } private Authorization authorization(final String groupName, final String roleName) { @@ -826,225 +818,433 @@ private Authorization authorization(final String groupName, final String roleNam return new Authorization(group, role); } - private static final String XML_ROLE_CONF = "" - + " " - + " true" - + " AD_ROLE" - + " ROLE" - + " FULL" - + " " - + " " - + " false" - + " AD_GROUP" - + " GROUP" - + " FULL" - + " " - + " " - + " false" - + " AD_GROUPROLE" - + " ROLEGROUP" - + " _r_" - + " FULL" - + " " - + ""; - - private static final String XML_GROUP_CONF = "" + private static final String XML_ROLE_CONF = + "" + + "" + + "FULL" + + "true" + + "" + + " " + + " true" + + " AD_ROLE" + + " ROLE" + + " " + + " " + + " false" + + " AD_GROUP" + + " GROUP" + + " " + + " " + + " false" + + " AD_GROUPROLE" + + " ROLEGROUP" + + " _r_" + + " " + + "" + + "" + + "" + + " default-roles-entando-development" + + " offline_access" + + " uma_authorization" + + " " + + " " + + " imported_role" + + " imported_role2" + + " ruolo" + + " " + + " " + + " imported_group" + + " imported_group2" + + " " + + ""; + + private static final String XML_GROUP_CONF = "" + + "" + + " FULL" + + " true" + + "" + + " " + + " false" + + " AD_ROLE" + + " ROLE" + + " " + + " " + + " true" + + " AD_GROUP" + + " GROUP" + + " " + + " " + + " false" + + " AD_GROUPROLE" + + " ROLEGROUP" + + " _r_" + + " " + + " " + + " " + + " default-roles-entando-development" + + " offline_access" + + " uma_authorization" + + " " + + " " + + " imported_role" + + " imported_role2" + + " " + + " " + + " group" + + " imported_group2" + + " " + + ""; + + private static final String XML_GROUP_CONF_NO_PERSIST = "" + + "" + + " NONE" + + " true" + + "" + " " + " false" + " AD_ROLE" + " ROLE" - + " FULL" + " " + " " + " true" + " AD_GROUP" + " GROUP" - + " FULL" + " " + " " + " false" + " AD_GROUPROLE" + " ROLEGROUP" + " _r_" - + " FULL" + " " - + ""; - - private static final String XML_GROUP_CONF_NO_PERSIST = "" + + "" + +"" + + " default-roles-entando-development" + + " offline_access" + + " uma_authorization" + + " " + + " " + + " imported_role" + + " imported_role2" + + " " + + " " + + " imported_group" + + " group" + + " " + + ""; + + private static final String XML_GROUP_ROLE_CONF = "" + + "" + + " FULL" + + " true" + + "" + " " + " false" + " AD_ROLE" + " ROLE" - + " FULL" - + " " - + " " - + " true" - + " AD_GROUP" - + " GROUP" - + " NONE" - + " " - + " " - + " false" - + " AD_GROUPROLE" - + " ROLEGROUP" - + " _r_" - + " FULL" - + " " - + ""; - - private static final String XML_GROUP_ROLE_CONF = "" - + " " - + " false" - + " AD_ROLE" - + " ROLE" - + " FULL" + " " + " " + " false" + " AD_GROUP" + " GROUP" - + " FULL" + " " + " " + " true" + " AD_GROUPROLE" + " ROLEGROUP" + " _r_" - + " FULL" + " " - + ""; - - private static final String XML_GROUP_ROLE_CONF_NO_PERSIST = "" + + "" + + "" + + " default-roles-entando-development" + + " offline_access" + + " uma_authorization" + + " " + + " " + + " arole" + + " imported_role2" + + " " + + " " + + " imported_group" + + " agroup" + + " " + + ""; + + private static final String XML_GROUP_ROLE_CONF_NO_PERSIST = "" + + "" + + " NONE" + + " true" + + "" + " " + " false" + " AD_ROLE" + " ROLE" - + " FULL" + " " + " " + " false" + " AD_GROUP" + " GROUP" - + " FULL" + " " + " " + " true" + " AD_GROUPROLE" + " ROLEGROUP" + " _r_" - + " NONE" + " " - + ""; - - private static final String XML_WITH_IGNORE_GROUP = "" + + "" + + "" + + " default-roles-entando-development" + + " offline_access" + + " uma_authorization" + + " " + + " " + + " imported_role" + + " arole" + + " " + + " " + + " imported_group" + + " agroup" + + " " + + ""; + + private static final String XML_WITH_IGNORE_GROUP = "" + + "FULL" + + " true" + + "" + " " + " true" + " groups" + " GROUPCLAIM" - + " FULL" + " " - + " altro-gruppo" - + ""; - - private static final String XML_WITH_IGNORE = "" + + "" + + " altro-gruppo" + + "" + + " imported_role" + + " imported_role2" + + " " + + " " + + " Gruppo-Microsoft-Importato" + + " imported_group2" + + " " + + ""; + + private static final String XML_WITH_IGNORE = "" + + " FULL" + + " true" + + "" + " " + " true" + " realm_access.roles" + " ROLECLAIM" - + " FULL" + " " - + " generico" - + " another_ignored" - + ""; - - private static final String XML_ROLE_CLAIM = "" + + "" + + "" + + " offline_access" + + " uma_authorization" + + " default-roles-entando" + + "" + + "" + + " generico" + + " imported_role2" + + " " + + " " + + " imported_group" + + " imported_group2" + + " " + + ""; + + private static final String XML_ROLE_CLAIM = "" + + " FULL" + + " true" + + "" + " " + " true" + " realm_access.roles" + " ROLECLAIM" - + " FULL" + " " - + ""; - - private static final String XML_ROLE_CLAIM_AUTH = "" + + "" + + "" + + " default-roles-entando-development" + + " offline_access" + + " uma_authorization" + + " default-roles-entando" + + " " + + " " + + " generico" + + " imported_role2" + + " " + + " " + + " imported_group" + + " imported_group2" + + " " + + ""; + + private static final String XML_ROLE_CLAIM_AUTH = "" + + " AUTH" + + " true" + + "" + " " + " true" + " realm_access.roles" + " ROLECLAIM" - + " AUTH" + " " - + ""; - - private static final String XML_GROUP_CLAIM = "" + + "" + + "" + + " default-roles-entando-development" + + " offline_access" + + " uma_authorization" + + " default-roles-entando" + + " " + + " " + + " generico" + + " imported_role2" + + " " + + " " + + " imported_group" + + " imported_group2" + + " " + + ""; + + private static final String XML_GROUP_CLAIM = "" + + "" + + " FULL" + + " true" + + "" + " " + " true" + " groups" + " GROUPCLAIM" - + " FULL" + " " - + ""; - - private static final String XML_GROUP_CLAIM_AUTH = "" + + "" + + "" + + " default-roles-entando-development" + + " offline_access" + + " uma_authorization" + + " " + + " " + + " imported_role" + + " imported_role2" + + " " + + " " + + " altro-gruppo" + + " Gruppo-Microsoft-Importato" + + " " + + ""; + + private static final String XML_GROUP_CLAIM_AUTH = "" + + "" + + " AUTH" + + " true" + + "" + " " + " true" + " groups" + " GROUPCLAIM" - + " AUTH" + " " - + ""; - - private static final String XML_ROLEGROUP_CLAIM = "" + + "" + + "" + + " default-roles-entando-development" + + " offline_access" + + " uma_authorization" + + " " + + " " + + " imported_role" + + " imported_role2" + + " " + + " " + + " altro-gruppo" + + " Gruppo-Microsoft-Importato" + + " " + + ""; + + private static final String XML_ROLEGROUP_CLAIM = "" + + " FULL" + + " true" + + "" + " " + " true" + " realm_access.roles" + " ROLEGROUPCLAIM" - + " FULL" + " _SEP_" + " " - + ""; - - private static final String XML_ROLEGROUP_CLAIM_AUTH = "" + + "" + + "" + + " default-roles-entando-development" + + " offline_access" + + " uma_authorization" + + " " + + " " + + " role1" + + " role2" + + " group1" + + " " + + " " + + " group1" + + " group2" + + " " + + ""; + + private static final String XML_ROLEGROUP_CLAIM_AUTH = "" + + " AUTH" + + " true" + + "" + " " + " true" + " realm_access.roles" + " ROLEGROUPCLAIM" - + " AUTH" + " _SEP_" + " " - + ""; - - private static final String XML_NO_MAPPING = "" - + ""; + + "" + + "" + + " default-roles-entando-development" + + " offline_access" + + " uma_authorization" + + " " + + " " + + " role1" + + " role2" + + " " + + " " + + " group1" + + " group2" + + " " + + ""; + + private static final String XML_NO_MAPPING = "" + + " FULL" + + " true" + + "" + + "" + +""; private static final String XML_MALFORMED_MAPPING = "" + "FULL" + + " true" + + "" + " " + " false" + " AD_ROLE" // + " ROLE" // kind null - + " FULL" + " " + " " + " true" + " AD_GROUP" + " GROUP" // unknown - + " FULL" + " " + + " " + " false" // + " AD_GROUPROLE" // attribute null + " ROLEGROUP" + " _r_" - + " FULL" + " " + " " + " false" + " AD_ROLE" + " ROLECLAIM" // no path - + " FULL" + " " + " " @@ -1052,18 +1252,31 @@ private Authorization authorization(final String groupName, final String roleNam + " AD_GROUPROLE" + " ROLEGROUP" // + " _r_" // separator null - + " FULL" + " " - + ""; + + "" + + "" + + " default-roles-entando-development" + + " offline_access" + + " uma_authorization" + + " " + + " " + + " imported_role" + + " imported_role2" + + " " + + " " + + " imported_group" + + " imported_group2" + + " " + + ""; - private static final String JWT_NO_ROLE = "{\n" - + " \"header\" : {\n" + private static final String JWT_NO_ROLE = "{" + + " \"header\" : {" + " \"alg\" : \"RS256\"," + " \"typ\" : \"JWT\"," - + " \"kid\" : \"l09Wlf_NY_dmMORYBjkr7deFVGVJ5TRLHW1p7DIT1ds\"\n" + + " \"kid\" : \"l09Wlf_NY_dmMORYBjkr7deFVGVJ5TRLHW1p7DIT1ds\"" + " }," - + " \"payload\" : {\n" + + " \"payload\" : {" + " \"exp\" : 1768319443," + " \"iat\" : 1768319143," + " \"auth_time\" : 1768319142," @@ -1077,16 +1290,16 @@ private Authorization authorization(final String groupName, final String roleNam + " \"session_state\" : \"0503e261-d522-41b6-8096-1debdd2c86e7\"," + " \"acr\" : \"1\"," + " \"allowed-origins\" : [ \"https://localhost:8080\", \"*\" ]," - + " \"realm_access\" : {\n" - + " \"roles\" : [ \"offline_access\", \"uma_authorization\", \"default-roles-entando\" ]\n" + + " \"realm_access\" : {" + + " \"roles\" : [ \"offline_access\", \"uma_authorization\", \"default-roles-entando\" ]" + " }," - + " \"resource_access\" : {\n" - + " \"aclient\" : {\n" - + " \"roles\" : [ \"generico\" ]\n" + + " \"resource_access\" : {" + + " \"aclient\" : {" + + " \"roles\" : [ \"generico\" ]" + " }," - + " \"account\" : {\n" - + " \"roles\" : [ \"manage-account\", \"manage-account-links\", \"view-profile\" ]\n" - + " }\n" + + " \"account\" : {" + + " \"roles\" : [ \"manage-account\", \"manage-account-links\", \"view-profile\" ]" + + " }" + " }," + " \"scope\" : \"openid profile email\"," + " \"sid\" : \"0503e261-d522-41b6-8096-1debdd2c86e7\"," @@ -1096,12 +1309,12 @@ private Authorization authorization(final String groupName, final String roleNam + " \"given_name\" : \"User\"," + " \"family_name\" : \"lastname\"," + " \"email\" : \"user@email.it\"," - + " \"miei_ruoli_custom\" : [ \"offline_access\", \"uma_authorization\", \"default-roles-entando\" ]\n" + + " \"miei_ruoli_custom\" : [ \"offline_access\", \"uma_authorization\", \"default-roles-entando\" ]" + " }," - + " \"signature\" : \"dLENSPEPw\"\n" + + " \"signature\" : \"dLENSPEPw\"" + "}"; - private static final String JWT = "{\n" + private static final String JWT = "{" + " \"exp\" : 1768319443," + " \"iat\" : 1768319143," + " \"auth_time\" : 1768319142," @@ -1115,32 +1328,32 @@ private Authorization authorization(final String groupName, final String roleNam + " \"session_state\" : \"0503e261-d522-41b6-8096-1debdd2c86e7\"," + " \"acr\" : \"1\"," + " \"allowed-origins\" : [ \"https://localhost:8080\", \"*\" ]," - + " \"realm_access\" : {\n" - + " \"roles\" : [ \"offline_access\", \"uma_authorization\", \"default-roles-entando\", \"generico\" ]\n" + + " \"realm_access\" : {" + + " \"roles\" : [ \"offline_access\", \"uma_authorization\", \"default-roles-entando\", \"generico\" ]" + " }," - + " \"resource_access\" : {\n" - + " \"sim730\" : {\n" - + " \"roles\" : [ \"generico\" ]\n" + + " \"resource_access\" : {" + + " \"sim730\" : {" + + " \"roles\" : [ \"generico\" ]" + " }," - + " \"account\" : {\n" - + " \"roles\" : [ \"manage-account\", \"manage-account-links\", \"view-profile\" ]\n" - + " }\n" + + " \"account\" : {" + + " \"roles\" : [ \"manage-account\", \"manage-account-links\", \"view-profile\" ]" + + " }" + " }," + " \"scope\" : \"openid profile email\"," + " \"sid\" : \"0503e261-d522-41b6-8096-1debdd2c86e7\"," + " \"email_verified\" : false," + " \"name\" : \"User lastname\"," - + " \"groups\": [\n" + + " \"groups\": [" + " \"Gruppo-Microsoft-Importato\", \"altro-gruppo\" " + " ]," + " \"preferred_username\" : \"user@email.it\"," + " \"given_name\" : \"User\"," + " \"family_name\" : \"lastname\"," + " \"email\" : \"user@email.it\"," - + " \"miei_ruoli_custom\" : [ \"offline_access\", \"uma_authorization\", \"default-roles-entando\" ]\n" + + " \"miei_ruoli_custom\" : [ \"offline_access\", \"uma_authorization\", \"default-roles-entando\" ]" + " }"; - private static final String JWT_ROLEGROUP = "{\n" + private static final String JWT_ROLEGROUP = "{" + " \"exp\" : 1768319443," + " \"iat\" : 1768319143," + " \"auth_time\" : 1768319142," @@ -1154,8 +1367,8 @@ private Authorization authorization(final String groupName, final String roleNam + " \"session_state\" : \"0503e261-d522-41b6-8096-1debdd2c86e7\"," + " \"acr\" : \"1\"," + " \"allowed-origins\" : [ \"https://localhost:8080\", \"*\" ]," - + " \"realm_access\" : {\n" - + " \"roles\" : [ \"role1_SEP_group1\", \"role2_SEP_group2\" ]\n" + + " \"realm_access\" : {" + + " \"roles\" : [ \"role1_SEP_group1\", \"role2_SEP_group2\" ]" + " }," + " \"scope\" : \"openid profile email\"," + " \"sid\" : \"0503e261-d522-41b6-8096-1debdd2c86e7\"," @@ -1167,7 +1380,7 @@ private Authorization authorization(final String groupName, final String roleNam + " \"email\" : \"user@email.it\"" + " }"; - private static final String JWT_ROLEGROUP_EDGE = "{\n" + private static final String JWT_ROLEGROUP_EDGE = "{" + " \"exp\" : 1768319443," + " \"iat\" : 1768319143," + " \"auth_time\" : 1768319142," @@ -1181,8 +1394,8 @@ private Authorization authorization(final String groupName, final String roleNam + " \"session_state\" : \"0503e261-d522-41b6-8096-1debdd2c86e7\"," + " \"acr\" : \"1\"," + " \"allowed-origins\" : [ \"https://localhost:8080\", \"*\" ]," - + " \"realm_access\" : {\n" - + " \"roles\" : [ \"group1\", \"_SEP_group2\" ]\n" + + " \"realm_access\" : {" + + " \"roles\" : [ \"group1\", \"_SEP_group2\" ]" + " }," + " \"scope\" : \"openid profile email\"," + " \"sid\" : \"0503e261-d522-41b6-8096-1debdd2c86e7\"," From c7d32ca565ebfe592e7de1363aee050d0195e9f6 Mon Sep 17 00:00:00 2001 From: "Matteo E. Minnai" Date: Wed, 25 Feb 2026 22:58:51 +0100 Subject: [PATCH 28/44] ESB-950 Introduced lock-striping --- .../authorization/AuthorizationDAO.java | 8 +- .../authorization/TestAuthorizationDAO.java | 10 +- .../KeycloakAuthenticationFilter.java | 32 ++-- .../keycloak/filter/KeycloakFilter.java | 1 - .../KeycloakAuthorizationManager.java | 26 ++-- .../KeycloakAuthenticationFilterTest.java | 3 +- ...ycloakAuthorizationManagerComplexTest.java | 139 ------------------ .../KeycloakAuthorizationManagerTest.java | 15 +- 8 files changed, 48 insertions(+), 186 deletions(-) diff --git a/engine/src/main/java/com/agiletec/aps/system/services/authorization/AuthorizationDAO.java b/engine/src/main/java/com/agiletec/aps/system/services/authorization/AuthorizationDAO.java index 01abadfea..55eb61d08 100644 --- a/engine/src/main/java/com/agiletec/aps/system/services/authorization/AuthorizationDAO.java +++ b/engine/src/main/java/com/agiletec/aps/system/services/authorization/AuthorizationDAO.java @@ -17,19 +17,16 @@ import com.agiletec.aps.system.common.FieldSearchFilter; import com.agiletec.aps.system.services.group.Group; import com.agiletec.aps.system.services.role.Role; - import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; -import java.sql.SQLException; import java.sql.Types; import java.util.ArrayList; import java.util.List; import java.util.Map; - import org.apache.commons.collections.CollectionUtils; -import org.entando.entando.ent.util.EntLogging.EntLogger; import org.entando.entando.ent.util.EntLogging.EntLogFactory; +import org.entando.entando.ent.util.EntLogging.EntLogger; /** * @author E.Santoboni @@ -203,7 +200,7 @@ private String createSqlForAuthDeletion(final String username, final List>> " + sb.toString()); return sb.toString(); } diff --git a/engine/src/test/java/com/agiletec/aps/system/services/authorization/TestAuthorizationDAO.java b/engine/src/test/java/com/agiletec/aps/system/services/authorization/TestAuthorizationDAO.java index 536d8b06e..537d7a66a 100644 --- a/engine/src/test/java/com/agiletec/aps/system/services/authorization/TestAuthorizationDAO.java +++ b/engine/src/test/java/com/agiletec/aps/system/services/authorization/TestAuthorizationDAO.java @@ -22,7 +22,7 @@ void testDeleteUserAuthorizationByGroupAndRole() throws Throwable { String username = "admin"; - // 1. Setup: Add multiple authorizations + // Setup with multiple authorizations Group freeGroup = new Group(); freeGroup.setName("free"); Role editorRole = new Role(); @@ -59,7 +59,7 @@ void testDeleteUserAuthorizationByGroupAndRole() throws Throwable { assertTrue(containsAuth(authorizations, "coach", "pageManager")); assertTrue(containsAuth(authorizations, "customers", "supervisor")); - // 2. Test deleteUserAuthorizationByGroupAndRole with specific groups and roles + //Test deleteUserAuthorizationByGroupAndRole with specific groups and roles authorizationDAO.deleteUserAuthorizationByGroupAndRole( username, Arrays.asList("free", "coach"), @@ -98,7 +98,7 @@ void testDeleteUserAuthorizationByGroupAndRoleWithOnlyGroups() throws Throwable Map groups = Map.of("free", freeGroup, "coach", coachGroup); Map roles = Map.of("editor", editorRole, "pageManager", pageManagerRole); - // Delete by groups only + // Delete it by groups only authorizationDAO.deleteUserAuthorizationByGroupAndRole( username, Arrays.asList("free"), @@ -111,7 +111,7 @@ void testDeleteUserAuthorizationByGroupAndRoleWithOnlyGroups() throws Throwable } @Test - void testDeleteUserAuthorizationByGroupAndRoleWithOnlyRoles() throws Throwable { + void testDeleteUserAuthorizationByGroupAndRoleWithOnlyRoles() { DataSource dataSource = (DataSource) this.getApplicationContext().getBean("servDataSource"); AuthorizationDAO authorizationDAO = new AuthorizationDAO(); authorizationDAO.setDataSource(dataSource); @@ -136,7 +136,7 @@ void testDeleteUserAuthorizationByGroupAndRoleWithOnlyRoles() throws Throwable { Map groups = Map.of("free", freeGroup, "coach", coachGroup); Map roles = Map.of("editor", editorRole, "pageManager", pageManagerRole); - // Delete by roles only + // Delete it by roles only authorizationDAO.deleteUserAuthorizationByGroupAndRole( username, null, 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 ae1591171..d2da68369 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 @@ -55,6 +55,9 @@ public class KeycloakAuthenticationFilter extends AbstractAuthenticationProcessi private final IAuthenticationProviderManager authenticationProviderManager; private final KeycloakAuthorizationManager keycloakGroupManager; + private static final int STRIPES = 256; + private final Object[] stripedLocks = new Object[STRIPES]; + @Autowired public KeycloakAuthenticationFilter(final KeycloakConfiguration configuration, final IUserManager userManager, @@ -69,6 +72,10 @@ public KeycloakAuthenticationFilter(final KeycloakConfiguration configuration, this.userManager = userManager; this.oidcService = oidcService; this.authenticationProviderManager = authenticationProviderManager; + // format the lock cache + for (int i = 0; i < STRIPES; i++) { + stripedLocks[i] = new Object(); + } } @Override @@ -100,21 +107,24 @@ public Authentication attemptAuthentication(final HttpServletRequest request, fi } try { - final UserDetails user = authenticationProviderManager.getUser(accessToken.getUsername()); - final UserAuthentication userAuthentication = new UserAuthentication(user); + int index = accessToken.getUsername().hashCode() & (STRIPES - 1); + Object lock = stripedLocks[index]; - ofNullable(accessToken.getResourceAccess()) - .map(access -> access.get(configuration.getClientId())) - .map(TokenRoles::getRoles) - .ifPresent(permissions -> addAuthorizations(permissions, user)); + // user-based lock, collisions are still possible, but it is acceptable + synchronized (lock) { + final UserDetails user = authenticationProviderManager.getUser(accessToken.getUsername()); + final UserAuthentication userAuthentication = new UserAuthentication(user); - setUserOnContext(request, user, userAuthentication); + ofNullable(accessToken.getResourceAccess()) + .map(access -> access.get(configuration.getClientId())) + .map(TokenRoles::getRoles) + .ifPresent(permissions -> addAuthorizations(permissions, user)); - // TODO optimise to not check on every request -// keycloakGroupManager.cleanupManagedAuthorizations(user.getUsername()); - keycloakGroupManager.processNewUser(user, bearerToken, true); + setUserOnContext(request, user, userAuthentication); - return userAuthentication; + keycloakGroupManager.processNewUser(user, bearerToken, true); + return userAuthentication; + } } catch (EntException e) { log.error("System exception", e); throw new InsufficientAuthenticationException("error parsing OAuth parameters"); 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 03a47590f..131d545a0 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 @@ -252,7 +252,6 @@ private void doLogin(final HttpServletRequest request, final HttpServletResponse throw new EntandoTokenException("invalid or expired token", request, "guest"); } -// keycloakGroupManager.cleanupManagedAuthorizations(tokenResponse.getBody().getUsername()); final UserDetails user = providerManager.getUser(tokenResponse.getBody().getUsername()); session.setAttribute(SESSION_PARAM_ACCESS_TOKEN, responseEntity.getBody().getAccessToken()); session.setAttribute(SESSION_PARAM_ID_TOKEN, responseEntity.getBody().getIdToken()); 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 1b83c1324..5c6f7032b 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 @@ -227,11 +227,11 @@ private void syncAuthorizations(final UserDetails user, final List existingAuths = Optional.ofNullable(user.getAuthorizations()) .orElse(List.of()) .stream() - .filter(a -> (a.getGroup() != null && groups.contains(a.getRole().getName()) + .filter(a -> (a.getGroup() != null && groups.contains(a.getGroup().getName()) || (a.getRole() != null && roles.contains(a.getRole().getName()))) ) .collect(Collectors.toList()); - // se l'autorizzazione esistente non è contenuta nelle dynamic auths allora va cancellata + // If the existing authorization is not included in the dynamic authorizations, it must be removed List toDelete = existingAuths .stream() .filter(a -> { @@ -240,7 +240,7 @@ private void syncAuthorizations(final UserDetails user, final List toAdd, } public static int indexOfAuthorization(UserDetails user, Authorization target) { - if (user == null || target == null) { return -1; } - List authorizations = user.getAuthorizations(); + final List authorizations = user.getAuthorizations(); + if (authorizations == null || authorizations.isEmpty()) { return -1; } @@ -301,10 +301,11 @@ public static int indexOfAuthorization(UserDetails user, Authorization target) { return i; } } - + // oops return -1; } +/**/ private static void sillyDebug(UserDetails user, List dynamicAuthorizations, List existingAuths, List toAdd, List toDelete) { System.out.println("-------------------\n"); @@ -333,6 +334,7 @@ private static void sillyDebug(UserDetails user, List dynamicAuth System.out.println("DELETE " + user.getUsername() + " role " + roleName + " group " + groupName); }); } +/**/ /** * Analyze the JWT looking for known mappings to translate into Entando roles @@ -516,8 +518,7 @@ private List doProcessRoleGroup(KeycloakUser user, DynamicMapping return result; } - private Authorization parseAuthForRoleGroup(KeycloakUser user, DynamicMappingElement elem, String groupRoleToken, String separator) - throws EntException { + private Authorization parseAuthForRoleGroup(KeycloakUser user, DynamicMappingElement elem, String groupRoleToken, String separator) { final String[] tokens = groupRoleToken.split(separator); if (tokens.length != 2 @@ -568,7 +569,7 @@ private List finalizeRoleAssociation(KeycloakUser user, DynamicMa return result; } - private Authorization finalizeAssociation(KeycloakUser user, DynamicMappingElement elem, String roleName, String groupName) throws EntException { + private Authorization finalizeAssociation(KeycloakUser user, DynamicMappingElement elem, String roleName, String groupName) { return finalizeAssociation(user, elem, roleName, groupName, true); } @@ -581,18 +582,17 @@ private boolean isIgnored(String name) { private Authorization finalizeAssociation(KeycloakUser user, DynamicMappingElement elem, String roleName, String groupName, boolean createRoleIfMissing) { - // is ignored? + // is it excluded? if (isIgnored(roleName) || isIgnored(groupName)) { log.info("Role {} or Group {} is in the exclusions list. Skipping assignment for user {}", roleName, groupName, user.getUsername()); return null; } - if (StringUtils.isNotBlank(roleName) &&!roles.contains(roleName)) { - System.out.println(">>> IGNORO RUOLO " + roleName); + // are they managed? + if (StringUtils.isNotBlank(roleName) && !roles.contains(roleName)) { log.info("Role {} is not managed. Skipping assignment for user {}", roleName, user.getUsername()); return null; } if (StringUtils.isNotBlank(groupName) && !groups.contains(groupName)) { - System.out.println(">>> IGNORO GRUPPO " + groupName); log.info("Group {} is not managed. Skipping assignment for user {}", groupName, user.getUsername()); return null; } diff --git a/keycloak-plugin/src/test/java/org/entando/entando/aps/servlet/security/KeycloakAuthenticationFilterTest.java b/keycloak-plugin/src/test/java/org/entando/entando/aps/servlet/security/KeycloakAuthenticationFilterTest.java index b20dcf1a4..303b142c7 100644 --- a/keycloak-plugin/src/test/java/org/entando/entando/aps/servlet/security/KeycloakAuthenticationFilterTest.java +++ b/keycloak-plugin/src/test/java/org/entando/entando/aps/servlet/security/KeycloakAuthenticationFilterTest.java @@ -5,7 +5,6 @@ import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doNothing; -import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -169,4 +168,4 @@ private void mockForAttemptAuthenticationTest() throws Exception { when(configuration.getClientId()).thenReturn("clientId"); when(resourceAccess.get(anyString())).thenReturn(tokenRoles); } -} \ No newline at end of file +} diff --git a/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerComplexTest.java b/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerComplexTest.java index b5d1048e6..cf9b2f8c5 100644 --- a/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerComplexTest.java +++ b/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerComplexTest.java @@ -7,7 +7,6 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.lenient; -import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -31,7 +30,6 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; -import org.mockito.internal.matchers.Any; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.security.authentication.ProviderManager; @@ -387,141 +385,4 @@ private KeycloakUser createKeycloakUser(String username, String attrName, List authCaptor = ArgumentCaptor.forClass(Authorization.class); @@ -388,7 +387,7 @@ void testDynamicConfigurationGroupRoleOnLoginAlreadyPresent() throws Exception { UserRepresentation userRepresentation = new UserRepresentation(); userRepresentation.setAttributes(Map.of("AD_GROUPROLE", List.of("arole_r_agroup"))); - when(userDetails.getUsername()).thenReturn("testuser"); +// when(userDetails.getUsername()).thenReturn("testuser"); when(userDetails.getUserRepresentation()).thenReturn(userRepresentation); when(userDetails.getAuthorizations()).thenReturn(List.of(auth)); @@ -700,11 +699,9 @@ void testAuthAssignmentWhenRoleGroupExistWithPersistAuth() throws Exception { + " ROLE" + " " + "" - + " " + " existing_role" + " " - + ""; when(configuration.getDefaultAuthorizations()).thenReturn(null); @@ -717,7 +714,7 @@ void testAuthAssignmentWhenRoleGroupExistWithPersistAuth() throws Exception { UserRepresentation userRepresentation = new UserRepresentation(); userRepresentation.setAttributes(Map.of("AD_ROLE", List.of("existing_role"))); when(userDetails.getUserRepresentation()).thenReturn(userRepresentation); - when(userDetails.getUsername()).thenReturn("testuser"); +// when(userDetails.getUsername()).thenReturn("testuser"); when(userDetails.getAuthorizations()).thenReturn(new ArrayList<>()); manager.init(); @@ -755,19 +752,19 @@ void testAuthAssignmentWhenRoleExistsAndAddRoleFailsWithPersistAuth() throws Exc Role existingRole = new Role(); existingRole.setName("conflict_role"); - // Prima ritorna null (simulando che non lo trova), poi dopo l'errore di addRole lo trova + // First it returns null (simulating that it can’t find it), then after the addRole error, it finds it when(roleManager.getRole("conflict_role")) .thenReturn(null) .thenReturn(existingRole); - // Simula conflitto su addRole + // Simulate a conflict on addRole org.mockito.Mockito.doThrow(new EntException("Conflict")) .when(roleManager).addRole(any(Role.class)); UserRepresentation userRepresentation = new UserRepresentation(); userRepresentation.setAttributes(Map.of("AD_ROLE", List.of("conflict_role"))); when(userDetails.getUserRepresentation()).thenReturn(userRepresentation); - when(userDetails.getUsername()).thenReturn("testuser"); +// when(userDetails.getUsername()).thenReturn("testuser"); when(userDetails.getAuthorizations()).thenReturn(new ArrayList<>()); manager.init(); From 6567808004fd7b6d4f5df275c58736f2b68fb838 Mon Sep 17 00:00:00 2001 From: "Matteo E. Minnai" Date: Wed, 25 Feb 2026 23:56:10 +0100 Subject: [PATCH 29/44] ESB-950 Minor performance improvement --- .../KeycloakAuthorizationManager.java | 45 +++++++++++++------ .../KeycloakAuthorizationManagerTest.java | 2 +- 2 files changed, 32 insertions(+), 15 deletions(-) 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 5c6f7032b..3a0ff9d80 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 @@ -212,7 +212,7 @@ public void processNewUser(final UserDetails user, final String token, final boo } private void syncAuthorizations(final UserDetails user, final List dynamicAuthorizations) throws EntException { - // se l'autorizzazione dinamica non è già assegnata all'utente allora va aggiunta + //If the dynamic authorization is not already assigned to the user, then it must be added List toAdd = dynamicAuthorizations .stream() .filter(a -> { @@ -220,7 +220,7 @@ private void syncAuthorizations(final UserDetails user, final List toAdd, List toDelete) throws EntException { - // finally + // add the newly granted authorizations + addAuthorizations(user, toAdd); + // remove authorizations that aren't granted anymore + if (!toDelete.isEmpty()) { + deleteAuthorizations(user, toDelete); + } + } + + private void addAuthorizations(UserDetails user, List toAdd) { for (Authorization authorization : toAdd) { if (persist == PersistKind.FULL) { @@ -256,26 +264,31 @@ private void persistAuthorizations(UserDetails user, List toAdd, log.debug("Failed to persist authorization for user {}: {}", user.getUsername(), e.getMessage()); } } + // sync authorizations user.addAuthorization(authorization); } - List index = new ArrayList<>(); - for (Authorization authorization: toDelete) { - List rolesToDelete = new ArrayList<>(); - List groupsToDelete = new ArrayList<>(); + } + private void deleteAuthorizations(UserDetails user, List toDelete) throws EntException { + final List index = new ArrayList<>(); + final List rolesToDelete = new ArrayList<>(); + final List groupsToDelete = new ArrayList<>(); + + for (Authorization authorization: toDelete) { if (authorization.getRole() != null) { rolesToDelete.add(authorization.getRole().getName()); } if (authorization.getGroup() != null) { groupsToDelete.add(authorization.getGroup().getName()); } - if (persist == PersistKind.FULL) { - authorizationManager.deleteUserAuthorizationByGroupAndRole(user.getUsername(), groupsToDelete, rolesToDelete); - } index.add(indexOfAuthorization(user, authorization)); } + // execute a single delete + if (persist == PersistKind.FULL) { + authorizationManager.deleteUserAuthorizationByGroupAndRole(user.getUsername(), groupsToDelete, rolesToDelete); + } + // sync authorizations index.sort(Comparator.reverseOrder()); - // sync permissions without reloading user auths if (!index.isEmpty()) { index.stream() .filter(idx -> idx >= 0) @@ -596,7 +609,12 @@ private Authorization finalizeAssociation(KeycloakUser user, DynamicMappingEleme log.info("Group {} is not managed. Skipping assignment for user {}", groupName, user.getUsername()); return null; } - return createAuthorization(elem, roleName, groupName, createRoleIfMissing); + // further optimization +// if (!isAlreadyAssigned(user, groupName, roleName)) { + return createAuthorization(elem, roleName, groupName, createRoleIfMissing); +// } else { +// return null; +// } } private Authorization createAuthorization(DynamicMappingElement elem, String roleName, String groupName, boolean createRoleIfMissing) { @@ -629,7 +647,7 @@ private Role resolveTransientRole(String roleName, boolean createRoleIfMissing) return createRoleIfMissing ? createTransientRole(roleName) : roleManager.getRole(roleName); } - private boolean isAlreadyAssigned(final KeycloakUser user, final String roleName, final String groupName) { + private boolean isAlreadyAssigned(final KeycloakUser user, final String groupName, final String roleName) { return user.getAuthorizations().stream() .anyMatch(a -> { final String existingRoleName = (a.getRole() != null) ? a.getRole().getName() : null; @@ -640,7 +658,6 @@ private boolean isAlreadyAssigned(final KeycloakUser user, final String roleName }); } - private @NonNull Role createTransientRole(String roleName) { Role role = roleManager.getRole(roleName); if (role == null) { diff --git a/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java b/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java index 9a7051abc..8a287a9fc 100644 --- a/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java +++ b/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java @@ -389,7 +389,7 @@ void testDynamicConfigurationGroupRoleOnLoginAlreadyPresent() throws Exception { // when(userDetails.getUsername()).thenReturn("testuser"); when(userDetails.getUserRepresentation()).thenReturn(userRepresentation); - when(userDetails.getAuthorizations()).thenReturn(List.of(auth)); + when(userDetails.getAuthorizations()).thenReturn(new ArrayList<>(List.of(auth))); manager.init(); From bace58fd986a56b11eac9f61811594e2c391535c Mon Sep 17 00:00:00 2001 From: "Matteo E. Minnai" Date: Thu, 26 Feb 2026 00:07:29 +0100 Subject: [PATCH 30/44] ESB-950 Cosmetic changes --- .../system/services/authorization/AuthorizationManager.java | 2 +- .../keycloak/services/KeycloakAuthorizationManager.java | 4 ++-- .../servlet/security/KeycloakAuthenticationFilterTest.java | 2 +- .../keycloak/services/KeycloakAuthorizationManagerTest.java | 1 - 4 files changed, 4 insertions(+), 5 deletions(-) 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 ca3a04ee3..1a63eef53 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 @@ -65,7 +65,7 @@ public boolean isAuthOnGroupAndPermission(UserDetails user, String groupName, St if (null == user || null == groupName || null == permissionName) { return false; } - List roles = new ArrayList(); + List roles = new ArrayList<>(); List rolesWithPermission = this.getRoleManager().getRolesWithPermission(permissionName); if (null != rolesWithPermission) { roles.addAll(rolesWithPermission); 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 3a0ff9d80..052d919d0 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 @@ -611,13 +611,13 @@ private Authorization finalizeAssociation(KeycloakUser user, DynamicMappingEleme } // further optimization // if (!isAlreadyAssigned(user, groupName, roleName)) { - return createAuthorization(elem, roleName, groupName, createRoleIfMissing); + return createAuthorization(roleName, groupName, createRoleIfMissing); // } else { // return null; // } } - private Authorization createAuthorization(DynamicMappingElement elem, String roleName, String groupName, boolean createRoleIfMissing) { + private Authorization createAuthorization(String roleName, String groupName, boolean createRoleIfMissing) { if (shouldPersistAuthorization()) { return createPersistedAuthorization(roleName, groupName); } diff --git a/keycloak-plugin/src/test/java/org/entando/entando/aps/servlet/security/KeycloakAuthenticationFilterTest.java b/keycloak-plugin/src/test/java/org/entando/entando/aps/servlet/security/KeycloakAuthenticationFilterTest.java index 303b142c7..288294946 100644 --- a/keycloak-plugin/src/test/java/org/entando/entando/aps/servlet/security/KeycloakAuthenticationFilterTest.java +++ b/keycloak-plugin/src/test/java/org/entando/entando/aps/servlet/security/KeycloakAuthenticationFilterTest.java @@ -145,7 +145,7 @@ void attemptAuthenticationWithEmptyOrNullPermissionListShouldReturnEmptyAuthoriz } @Test - void apiAuthenticationShouldSetAttributeRequest() throws Exception { + void apiAuthenticationShouldSetAttributeRequest() { when(request.getServletPath()).thenReturn("/api"); try ( MockedStatic wacUtil = Mockito.mockStatic(WebApplicationContextUtils.class)) { wacUtil.when(() -> WebApplicationContextUtils.getWebApplicationContext(svCtx)).thenReturn(wac); diff --git a/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java b/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java index 8a287a9fc..0415b42aa 100644 --- a/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java +++ b/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java @@ -838,7 +838,6 @@ private Authorization authorization(final String groupName, final String roleNam + " _r_" + " " + "" - + "" + "" + " default-roles-entando-development" + " offline_access" From 932a12d627aa418abd23ccd82dd4dfbff9ddf911 Mon Sep 17 00:00:00 2001 From: "Matteo E. Minnai" Date: Thu, 26 Feb 2026 12:07:05 +0100 Subject: [PATCH 31/44] ESB-950 Improved DB usage, introduced synchronization table for external users --- .../authorization/AuthorizationDAO.java | 185 +++++++++++++++++- .../authorization/AuthorizationManager.java | 21 ++ .../authorization/IAuthorizationDAO.java | 6 + .../authorization/IAuthorizationManager.java | 7 +- .../resources/liquibase/changeSetServ.xml | 2 + .../serv/00000000000004_schemaServ.xml | 26 +++ .../authorization/TestAuthorizationDAO.java | 72 ++++--- .../TestAuthorizationManager.java | 74 ++++++- ...ternalSynchronizationAuthorizationDAO.java | 148 ++++++++++++++ .../KeycloakAuthenticationFilter.java | 31 +-- .../KeycloakAuthorizationManager.java | 53 ++--- .../services/oidc/OidcMappingService.java | 35 +++- 12 files changed, 568 insertions(+), 92 deletions(-) create mode 100644 engine/src/main/resources/liquibase/serv/00000000000004_schemaServ.xml create mode 100644 engine/src/test/java/com/agiletec/aps/system/services/authorization/TestExternalSynchronizationAuthorizationDAO.java diff --git a/engine/src/main/java/com/agiletec/aps/system/services/authorization/AuthorizationDAO.java b/engine/src/main/java/com/agiletec/aps/system/services/authorization/AuthorizationDAO.java index 55eb61d08..e94c92937 100644 --- a/engine/src/main/java/com/agiletec/aps/system/services/authorization/AuthorizationDAO.java +++ b/engine/src/main/java/com/agiletec/aps/system/services/authorization/AuthorizationDAO.java @@ -20,11 +20,13 @@ import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; +import java.sql.SQLException; import java.sql.Types; import java.util.ArrayList; import java.util.List; import java.util.Map; import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; import org.entando.entando.ent.util.EntLogging.EntLogFactory; import org.entando.entando.ent.util.EntLogging.EntLogger; @@ -32,9 +34,11 @@ * @author E.Santoboni */ public class AuthorizationDAO extends AbstractSearcherDAO implements IAuthorizationDAO { - + + public static final int BATCH_SIZE_FLUSH = 50; + private static final EntLogger _logger = EntLogFactory.getSanitizedLogger(AuthorizationDAO.class); - + @Override public void addUserAuthorization(String username, Authorization authorization) { if (null == authorization || null == username) return; @@ -167,10 +171,29 @@ public int deleteUserAuthorizationByGroupAndRole(final String username, final Li try { conn = this.getConnection(); conn.setAutoCommit(false); - stat = conn.prepareStatement(createSqlForAuthDeletion(username, groups, roles)); + final int rowsDeleted = doDeleteUserAuthorizationByGroupAndRole(conn, username, groups, roles); + conn.commit(); + return rowsDeleted; + } catch (Exception e) { + this.executeRollback(conn); + throw new RuntimeException("Error detected while deleting user authorizations", e); + } finally { + this.closeDaoResources(null, stat, conn); + } + } + + private int doDeleteUserAuthorizationByGroupAndRole(final Connection conn, final String username, final List groups, + final List roles) { + final boolean hasRoles = roles != null && !roles.isEmpty(); + final boolean hasGroups = groups != null && !groups.isEmpty(); + PreparedStatement stat; + + try { + stat = conn.prepareStatement(createSqlForAuthDeletion(username, groups, roles)); // username int index = 1; + stat.setString(index++, username); // groups if (hasGroups) { @@ -184,17 +207,167 @@ public int deleteUserAuthorizationByGroupAndRole(final String username, final Li stat.setString(index++, role); } } - final int rowsDeleted = stat.executeUpdate(); + return stat.executeUpdate(); + } catch (SQLException e) { + throw new RuntimeException("Error deleting user authorization", e); + } + } + + // Returns true if the user's external authentication synchronization is up to date + @Override + public boolean checkExternalAuthSync(final String username, final Long iat) { + Connection conn = null; + PreparedStatement stat = null; + + try { + conn = this.getConnection(); + + Long lastSyncedIat = null; + String userId = null; + + try (PreparedStatement selectStmt = conn.prepareStatement( + "SELECT username, iat FROM ext_sync WHERE username = ? FOR UPDATE")) { + selectStmt.setString(1, username); + + try (ResultSet rs = selectStmt.executeQuery()) { + if (rs.next()) { + userId = rs.getString("username"); + lastSyncedIat = rs.getLong("iat"); + } + } + } + + if (StringUtils.isBlank(userId)) { + _logger.debug("must track user {}", username); + return false; + } else if (iat > lastSyncedIat) { + _logger.debug("must synchronize user {}", username); + return false; + } + } catch (Exception e) { + throw new RuntimeException("Error detected while checking user synchronization", e); + } finally { + this.closeDaoResources(null, stat, conn); + } + return true; + } + + @Override + public void externalAuthSync(final String username, final Long iat, + final List toAdd, final List toRemove) { + Connection conn = null; + PreparedStatement stat = null; + + try { + conn = this.getConnection(); + conn.setAutoCommit(false); + + Long lastSyncedIat = null; + String usernameTracked = null; + + try (PreparedStatement selectStmt = conn.prepareStatement( + "SELECT username, iat FROM ext_sync WHERE username = ? FOR UPDATE")) { + selectStmt.setString(1, username); + + try (ResultSet rs = selectStmt.executeQuery()) { + if (rs.next()) { + usernameTracked = rs.getString("username"); + lastSyncedIat = rs.getLong("iat"); + } + } + } + + if (usernameTracked == null) { + try (PreparedStatement insertStmt = conn.prepareStatement( + "INSERT INTO ext_sync (username, iat) VALUES (?, ?)")) { + insertStmt.setString(1, username); + insertStmt.setLong(2, iat); + insertStmt.executeUpdate(); + } + + // update authorizations + deleteAuthorities(conn, username, toRemove); + addAuthorities(conn, username, toAdd); + + } else if (iat > lastSyncedIat) { + + // update authorizations + deleteAuthorities(conn, username, toRemove); + addAuthorities(conn, username, toAdd); + + // Aggiorna iat + try (PreparedStatement updateIat = conn.prepareStatement( + "UPDATE ext_sync SET iat = ? WHERE username = ?" + )) { + updateIat.setLong(1, iat); + updateIat.setString(2, username); + updateIat.executeUpdate(); + } + } else { + // do nothing // NOSONAR + } + conn.commit(); - return rowsDeleted; } catch (Exception e) { this.executeRollback(conn); - throw new RuntimeException("Error detected while deleting user authorizations", e); + throw new RuntimeException("Error detected while checking user synchronization", e); } finally { this.closeDaoResources(null, stat, conn); } } + private void deleteAuthorities(final Connection conn, final String username, final List list) { + if (conn == null || list == null || list.isEmpty() || StringUtils.isBlank(username)) return; + + final List groups = new ArrayList<>(); + final List roles = new ArrayList<>(); + + for (Authorization cur: list) { + if (cur.getGroup() != null) { + groups.add(cur.getGroup().getName()); + } + + if (cur.getRole() != null) { + roles.add(cur.getRole().getName()); + } + } + doDeleteUserAuthorizationByGroupAndRole(conn, username, groups, roles); + } + + private void addAuthorities(final Connection conn, final String username, final List list) + throws SQLException { + if (conn == null || list == null || list.isEmpty() || StringUtils.isBlank(username)) return; + + try (PreparedStatement stmt = conn.prepareStatement( + ADD_AUTHORIZATION + )) { + int batchSize = 0; + + for (Authorization cur : list) { + stmt.setString(1, username); + + if (cur.getGroup() != null) { + stmt.setString(2, cur.getGroup().getName()); + } else { + stmt.setNull(2, Types.VARCHAR); + } + + if (cur.getRole() != null) { + stmt.setString(3, cur.getRole().getName()); + } else { + stmt.setNull(3, Types.VARCHAR); + } + stmt.addBatch(); + + // avoid memory leaking + if (++batchSize % BATCH_SIZE_FLUSH == 0) { + stmt.executeBatch(); + } + } + stmt.executeBatch(); + } + } + private String createSqlForAuthDeletion(final String username, final List groups, final List roles) { final StringBuilder sb = new StringBuilder(DELETE_USER_AUTHORIZATIONS); final boolean hasRoles = roles != null && !roles.isEmpty(); 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 1a63eef53..59c660768 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 @@ -647,6 +647,27 @@ public void deleteUserAuthorizationByGroupAndRole(String username, List } } + @Override + public void externalAuthSync(String username, Long iat, List toAdd, List toRemove) + throws EntException { + try { + this.getAuthorizationDAO().externalAuthSync(username, iat, toAdd, toRemove); + } catch (Throwable t) { + _logger.error("Error syncing external authorization for user '{}'", username, t); + throw new EntException("Error syncing external authorization for user " + username, t); + } + } + + @Override + public boolean checkExternalAuthSync(final String username, final Long iat) throws EntException { + try { + return this.getAuthorizationDAO().checkExternalAuthSync(username, iat); + } catch (Exception t) { + _logger.error("Error checking synchronization status for user '{}'", username, t); + throw new EntException("Error checking synchronization status for user " + username, t); + } + } + @Override public List getUsersByRole(IApsAuthority authority, boolean includeAdmin) throws EntException { if (null == authority || !(authority instanceof Role) || null == this.getRoleManager().getRole(authority.getAuthority())) { diff --git a/engine/src/main/java/com/agiletec/aps/system/services/authorization/IAuthorizationDAO.java b/engine/src/main/java/com/agiletec/aps/system/services/authorization/IAuthorizationDAO.java index 89e45c2d2..747976bb4 100644 --- a/engine/src/main/java/com/agiletec/aps/system/services/authorization/IAuthorizationDAO.java +++ b/engine/src/main/java/com/agiletec/aps/system/services/authorization/IAuthorizationDAO.java @@ -39,4 +39,10 @@ public interface IAuthorizationDAO { public List getUsersByAuthorities(List groupNames, List roleNames); int deleteUserAuthorizationByGroupAndRole(String username, List groups, List roles); + + // Returns true if the user's external authentication synchronization is up to date + boolean checkExternalAuthSync(String username, Long iat); + + void externalAuthSync(String username, Long iat, + List toAdd, List toRemove); } diff --git a/engine/src/main/java/com/agiletec/aps/system/services/authorization/IAuthorizationManager.java b/engine/src/main/java/com/agiletec/aps/system/services/authorization/IAuthorizationManager.java index 586bb47ca..982a31b87 100644 --- a/engine/src/main/java/com/agiletec/aps/system/services/authorization/IAuthorizationManager.java +++ b/engine/src/main/java/com/agiletec/aps/system/services/authorization/IAuthorizationManager.java @@ -181,7 +181,12 @@ public interface IAuthorizationManager { void deleteUserAuthorizationByGroupAndRole(String username, List groups, List roles) throws EntException; - public List getUsersByRole(IApsAuthority authority, boolean includeAdmin) throws EntException; + void externalAuthSync(String username, Long iat, List toAdd, List toRemove) + throws EntException; + + boolean checkExternalAuthSync(String username, Long iat) throws EntException; + + public List getUsersByRole(IApsAuthority authority, boolean includeAdmin) throws EntException; public List getUsersByRole(String roleName, boolean includeAdmin) throws EntException; diff --git a/engine/src/main/resources/liquibase/changeSetServ.xml b/engine/src/main/resources/liquibase/changeSetServ.xml index 4a33efb97..8917d6d08 100644 --- a/engine/src/main/resources/liquibase/changeSetServ.xml +++ b/engine/src/main/resources/liquibase/changeSetServ.xml @@ -19,4 +19,6 @@ + + diff --git a/engine/src/main/resources/liquibase/serv/00000000000004_schemaServ.xml b/engine/src/main/resources/liquibase/serv/00000000000004_schemaServ.xml new file mode 100644 index 000000000..2daebddae --- /dev/null +++ b/engine/src/main/resources/liquibase/serv/00000000000004_schemaServ.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/engine/src/test/java/com/agiletec/aps/system/services/authorization/TestAuthorizationDAO.java b/engine/src/test/java/com/agiletec/aps/system/services/authorization/TestAuthorizationDAO.java index 537d7a66a..86868a692 100644 --- a/engine/src/test/java/com/agiletec/aps/system/services/authorization/TestAuthorizationDAO.java +++ b/engine/src/test/java/com/agiletec/aps/system/services/authorization/TestAuthorizationDAO.java @@ -4,23 +4,51 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import com.agiletec.aps.BaseTestCase; +import com.agiletec.aps.system.SystemConstants; import com.agiletec.aps.system.services.group.Group; +import com.agiletec.aps.system.services.group.IGroupManager; +import com.agiletec.aps.system.services.role.IRoleManager; import com.agiletec.aps.system.services.role.Role; +import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; import javax.sql.DataSource; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; class TestAuthorizationDAO extends BaseTestCase { - @Test - void testDeleteUserAuthorizationByGroupAndRole() throws Throwable { + private AuthorizationDAO authorizationDAO; + private List originalAuthorizations; + private final String USERNAME = "admin"; + + @BeforeEach + void init() throws Exception { DataSource dataSource = (DataSource) this.getApplicationContext().getBean("servDataSource"); - AuthorizationDAO authorizationDAO = new AuthorizationDAO(); - authorizationDAO.setDataSource(dataSource); + this.authorizationDAO = new AuthorizationDAO(); + this.authorizationDAO.setDataSource(dataSource); + + IGroupManager groupManager = (IGroupManager) this.getApplicationContext().getBean(SystemConstants.GROUP_MANAGER); + IRoleManager roleManager = (IRoleManager) this.getApplicationContext().getBean(SystemConstants.ROLE_MANAGER); - String username = "admin"; + Map rolesMap = roleManager.getRoles().stream().collect(Collectors.toMap(Role::getName, r -> r)); + this.originalAuthorizations = this.authorizationDAO.getUserAuthorizations(USERNAME, groupManager.getGroupsMap(), rolesMap); + } + + @AfterEach + void restore() { + this.authorizationDAO.deleteUserAuthorizations(USERNAME); + if (null != this.originalAuthorizations && !this.originalAuthorizations.isEmpty()) { + this.authorizationDAO.addUserAuthorizations(USERNAME, this.originalAuthorizations); + } + } + + @Test + void testDeleteUserAuthorizationByGroupAndRole() throws Throwable { + authorizationDAO.deleteUserAuthorizations(USERNAME); // Setup with multiple authorizations Group freeGroup = new Group(); @@ -41,7 +69,7 @@ void testDeleteUserAuthorizationByGroupAndRole() throws Throwable { supervisorRole.setName("supervisor"); Authorization auth3 = new Authorization(customersGroup, supervisorRole); - authorizationDAO.addUserAuthorizations(username, Arrays.asList(auth1, auth2, auth3)); + authorizationDAO.addUserAuthorizations(USERNAME, Arrays.asList(auth1, auth2, auth3)); Map groups = Map.of( "free", freeGroup, @@ -54,19 +82,19 @@ void testDeleteUserAuthorizationByGroupAndRole() throws Throwable { "supervisor", supervisorRole ); - List authorizations = authorizationDAO.getUserAuthorizations(username, groups, roles); + List authorizations = authorizationDAO.getUserAuthorizations(USERNAME, groups, roles); assertTrue(containsAuth(authorizations, "free", "editor")); assertTrue(containsAuth(authorizations, "coach", "pageManager")); assertTrue(containsAuth(authorizations, "customers", "supervisor")); //Test deleteUserAuthorizationByGroupAndRole with specific groups and roles authorizationDAO.deleteUserAuthorizationByGroupAndRole( - username, + USERNAME, Arrays.asList("free", "coach"), Arrays.asList("editor") ); - authorizations = authorizationDAO.getUserAuthorizations(username, groups, roles); + authorizations = authorizationDAO.getUserAuthorizations(USERNAME, groups, roles); assertFalse(containsAuth(authorizations, "free", "editor")); assertFalse(containsAuth(authorizations, "coach", "pageManager")); assertTrue(containsAuth(authorizations, "customers", "supervisor")); @@ -74,11 +102,7 @@ void testDeleteUserAuthorizationByGroupAndRole() throws Throwable { @Test void testDeleteUserAuthorizationByGroupAndRoleWithOnlyGroups() throws Throwable { - DataSource dataSource = (DataSource) this.getApplicationContext().getBean("servDataSource"); - AuthorizationDAO authorizationDAO = new AuthorizationDAO(); - authorizationDAO.setDataSource(dataSource); - - String username = "admin"; + authorizationDAO.deleteUserAuthorizations(USERNAME); // Setup Group freeGroup = new Group(); @@ -93,30 +117,26 @@ void testDeleteUserAuthorizationByGroupAndRoleWithOnlyGroups() throws Throwable pageManagerRole.setName("pageManager"); Authorization auth2 = new Authorization(coachGroup, pageManagerRole); - authorizationDAO.addUserAuthorizations(username, Arrays.asList(auth1, auth2)); + authorizationDAO.addUserAuthorizations(USERNAME, Arrays.asList(auth1, auth2)); Map groups = Map.of("free", freeGroup, "coach", coachGroup); Map roles = Map.of("editor", editorRole, "pageManager", pageManagerRole); // Delete it by groups only authorizationDAO.deleteUserAuthorizationByGroupAndRole( - username, + USERNAME, Arrays.asList("free"), null ); - List authorizations = authorizationDAO.getUserAuthorizations(username, groups, roles); + List authorizations = authorizationDAO.getUserAuthorizations(USERNAME, groups, roles); assertFalse(containsAuth(authorizations, "free", "editor")); assertTrue(containsAuth(authorizations, "coach", "pageManager")); } @Test void testDeleteUserAuthorizationByGroupAndRoleWithOnlyRoles() { - DataSource dataSource = (DataSource) this.getApplicationContext().getBean("servDataSource"); - AuthorizationDAO authorizationDAO = new AuthorizationDAO(); - authorizationDAO.setDataSource(dataSource); - - String username = "admin"; + authorizationDAO.deleteUserAuthorizations(USERNAME); // Setup Group freeGroup = new Group(); @@ -131,19 +151,19 @@ void testDeleteUserAuthorizationByGroupAndRoleWithOnlyRoles() { pageManagerRole.setName("pageManager"); Authorization auth2 = new Authorization(coachGroup, pageManagerRole); - authorizationDAO.addUserAuthorizations(username, Arrays.asList(auth1, auth2)); + authorizationDAO.addUserAuthorizations(USERNAME, Arrays.asList(auth1, auth2)); Map groups = Map.of("free", freeGroup, "coach", coachGroup); Map roles = Map.of("editor", editorRole, "pageManager", pageManagerRole); // Delete it by roles only authorizationDAO.deleteUserAuthorizationByGroupAndRole( - username, + USERNAME, null, Arrays.asList("editor") ); - List authorizations = authorizationDAO.getUserAuthorizations(username, groups, roles); + List authorizations = authorizationDAO.getUserAuthorizations(USERNAME, groups, roles); assertFalse(containsAuth(authorizations, "free", "editor")); assertTrue(containsAuth(authorizations, "coach", "pageManager")); } @@ -153,4 +173,6 @@ private boolean containsAuth(List authorizations, String group, S .anyMatch(a -> a.getGroup() != null && a.getGroup().getName().equals(group) && a.getRole() != null && a.getRole().getName().equals(role)); } + + /**/ } diff --git a/engine/src/test/java/com/agiletec/aps/system/services/authorization/TestAuthorizationManager.java b/engine/src/test/java/com/agiletec/aps/system/services/authorization/TestAuthorizationManager.java index 7d10e4cf0..1855310e3 100644 --- a/engine/src/test/java/com/agiletec/aps/system/services/authorization/TestAuthorizationManager.java +++ b/engine/src/test/java/com/agiletec/aps/system/services/authorization/TestAuthorizationManager.java @@ -49,7 +49,7 @@ class TestAuthorizationManager extends BaseTestCase { private GroupManager groupManager; @BeforeEach - private void init() { + public void init() { this.authenticationProvider = (IAuthenticationProviderManager) this.getService(SystemConstants.AUTHENTICATION_PROVIDER_MANAGER); this.authorizationManager = (IAuthorizationManager) this.getService(SystemConstants.AUTHORIZATION_SERVICE); this.userManager = (IUserManager) this.getService(SystemConstants.USER_MANAGER); @@ -407,6 +407,78 @@ void testUpdateAuthorization_1() throws Throwable { } } + @Test + void testExternalAuthSync() throws Throwable { + String username = "UserForSyncTest"; + String password = "PasswordForSyncTest"; + this.addUserForTest(username, password); + try { + List authorizations = this.authorizationManager.getUserAuthorizations(username); + assertEquals(1, authorizations.size()); + Authorization initialAuth = authorizations.get(0); + + long firstIat = 1000L; + List toAdd = new ArrayList<>(); + toAdd.add(new Authorization(this.groupManager.getGroup(Group.FREE_GROUP_NAME), this.roleManager.getRole("admin"))); + toAdd.add(new Authorization(this.groupManager.getGroup("coach"), null)); + + List toRemove = new ArrayList<>(); + // toRemove è vuoto inizialmente + + // Primo sync (iat = 1000) + this.authorizationManager.externalAuthSync(username, firstIat, toAdd, toRemove); + authorizations = this.authorizationManager.getUserAuthorizations(username); + // Dovrebbe avere initialAuth (editor/free) + admin/free + null/coach = 3 + assertEquals(3, authorizations.size()); + assertTrue(authorizations.contains(initialAuth)); + assertTrue(authorizations.contains(toAdd.get(0))); + assertTrue(authorizations.contains(toAdd.get(1))); + + // Secondo sync con iat inferiore (iat = 500) - Non dovrebbe cambiare nulla + long lowerIat = 500L; + List toAdd2 = new ArrayList<>(); + toAdd2.add(new Authorization(this.groupManager.getGroup("customers"), null)); + this.authorizationManager.externalAuthSync(username, lowerIat, toAdd2, new ArrayList<>()); + authorizations = this.authorizationManager.getUserAuthorizations(username); + assertEquals(3, authorizations.size()); + assertFalse(authorizations.contains(toAdd2.get(0))); + + // Terzo sync con lo stesso iat (iat = 1000) - Non dovrebbe cambiare nulla + this.authorizationManager.externalAuthSync(username, firstIat, toAdd2, new ArrayList<>()); + authorizations = this.authorizationManager.getUserAuthorizations(username); + assertEquals(3, authorizations.size()); + + // Quarto sync con iat superiore (iat = 2000) - Dovrebbe aggiungere e rimuovere + long higherIat = 2000L; + List toRemoveFinal = new ArrayList<>(); + toRemoveFinal.add(new Authorization(null, this.roleManager.getRole("admin"))); // rimuovo admin/free + toRemoveFinal.add(initialAuth); // rimuovo editor/free + + this.authorizationManager.externalAuthSync(username, higherIat, toAdd2, toRemoveFinal); + authorizations = this.authorizationManager.getUserAuthorizations(username); + // Rimangono: null/coach (da toAdd) + null/customers (da toAdd2) + assertEquals(2, authorizations.size()); + assertTrue(authorizations.contains(toAdd.get(1))); + assertTrue(authorizations.contains(toAdd2.get(0))); + assertFalse(authorizations.contains(toAdd.get(0))); + assertFalse(authorizations.contains(initialAuth)); + + // Test checkExternalAuthSync + // iat uguale all'ultimo (2000) -> deve ritornare true (sincronizzato) + assertTrue(this.authorizationManager.checkExternalAuthSync(username, higherIat)); + // iat minore (1500) -> deve ritornare true (già sincronizzato con un iat superiore) + assertTrue(this.authorizationManager.checkExternalAuthSync(username, 1500L)); + // iat maggiore (3000) -> deve ritornare false (necessita sincronizzazione) + assertFalse(this.authorizationManager.checkExternalAuthSync(username, 3000L)); + + } finally { + UserDetails user = this.userManager.getUser(username); + if (null != user) { + this.userManager.removeUser(user); + } + } + } + private void addUserForTest(String username, String password) throws Throwable { User user = new User(); user.setUsername(username); diff --git a/engine/src/test/java/com/agiletec/aps/system/services/authorization/TestExternalSynchronizationAuthorizationDAO.java b/engine/src/test/java/com/agiletec/aps/system/services/authorization/TestExternalSynchronizationAuthorizationDAO.java new file mode 100644 index 000000000..b28cf7261 --- /dev/null +++ b/engine/src/test/java/com/agiletec/aps/system/services/authorization/TestExternalSynchronizationAuthorizationDAO.java @@ -0,0 +1,148 @@ +package com.agiletec.aps.system.services.authorization; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.agiletec.aps.BaseTestCase; +import java.sql.Connection; +import java.sql.PreparedStatement; +import javax.sql.DataSource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class TestExternalSynchronizationAuthorizationDAO extends BaseTestCase { + + private AuthorizationDAO authorizationDAO; + + @BeforeEach + void setUpMethod() throws Exception { + DataSource dataSource = (DataSource) getApplicationContext().getBean("servDataSource"); + authorizationDAO = new AuthorizationDAO(); + authorizationDAO.setDataSource(dataSource); + this.cleanSyncTable(); + } + + + private void cleanSyncTable() throws Exception { + DataSource dataSource = (DataSource) this.getApplicationContext().getBean("servDataSource"); + try (Connection conn = dataSource.getConnection()) { + try (PreparedStatement stat = conn.prepareStatement("DELETE FROM ext_sync")) { + stat.executeUpdate(); + } + } + } + + @Test + void testCheckExternalAuthSync_NoUser() { + String username = "testUser"; + Long iat = 1000L; + // Se l'utente non esiste, deve restituire false + assertFalse(authorizationDAO.checkExternalAuthSync(username, iat)); + } + + @Test + void testCheckExternalAuthSync_OldIat() throws Exception { + String username = "testUser"; + Long currentIat = 1000L; + Long newIat = 2000L; + + // Inserisco manualmente un record + DataSource dataSource = (DataSource) this.getApplicationContext().getBean("servDataSource"); + try (Connection conn = dataSource.getConnection()) { + try (PreparedStatement stat = conn.prepareStatement("INSERT INTO ext_sync (username, iat) VALUES (?, ?)")) { + stat.setString(1, username); + stat.setLong(2, currentIat); + stat.executeUpdate(); + } + } + + // Se l'IAT fornito è maggiore di quello salvato, deve restituire false + assertFalse(authorizationDAO.checkExternalAuthSync(username, newIat)); + } + + @Test + void testCheckExternalAuthSync_SameIat() throws Exception { + String username = "testUser"; + Long currentIat = 1000L; + + // Inserisco manualmente un record + DataSource dataSource = (DataSource) this.getApplicationContext().getBean("servDataSource"); + try (Connection conn = dataSource.getConnection()) { + try (PreparedStatement stat = conn.prepareStatement("INSERT INTO ext_sync (username, iat) VALUES (?, ?)")) { + stat.setString(1, username); + stat.setLong(2, currentIat); + stat.executeUpdate(); + } + } + + // Se l'IAT è uguale, deve restituire true (sincronizzato) + assertTrue(authorizationDAO.checkExternalAuthSync(username, currentIat)); + } + + @Test + void testCheckExternalAuthSync_NewerIat() throws Exception { + String username = "testUser"; + Long currentIat = 2000L; + Long oldIat = 1000L; + + // Inserisco manualmente un record + DataSource dataSource = (DataSource) this.getApplicationContext().getBean("servDataSource"); + try (Connection conn = dataSource.getConnection()) { + try (PreparedStatement stat = conn.prepareStatement("INSERT INTO ext_sync (username, iat) VALUES (?, ?)")) { + stat.setString(1, username); + stat.setLong(2, currentIat); + stat.executeUpdate(); + } + } + + // Se l'IAT fornito è minore di quello salvato, deve restituire true (abbiamo già dati più recenti) + assertTrue(authorizationDAO.checkExternalAuthSync(username, oldIat)); + } + + @Test + void testExternalAuthSync_Insert() throws Exception { + String username = "testSync"; + Long iat = 3000L; + + authorizationDAO.externalAuthSync(username, iat, null, null); + + // Verifico che sia stato inserito + DataSource dataSource = (DataSource) getApplicationContext().getBean("servDataSource"); + try (Connection conn = dataSource.getConnection()) { + try (PreparedStatement stat = conn.prepareStatement("SELECT iat FROM ext_sync WHERE username = ?")) { + stat.setString(1, username); + try (var rs = stat.executeQuery()) { + assertTrue(rs.next()); + assertEquals(iat, rs.getLong("iat")); + } + } + } + } + + @Test + void testExternalAuthSync_Update() throws Exception { + String username = "testSync"; + Long initialIat = 3000L; + Long updatedIat = 4000L; + + // Inserimento iniziale + authorizationDAO.externalAuthSync(username, initialIat, null, null); + + // Aggiornamento + authorizationDAO.externalAuthSync(username, updatedIat, null, null); + + // Verifico che sia stato aggiornato + DataSource dataSource = (DataSource) getApplicationContext().getBean("servDataSource"); + try (Connection conn = dataSource.getConnection()) { + try (PreparedStatement stat = conn.prepareStatement("SELECT iat FROM ext_sync WHERE username = ?")) { + stat.setString(1, username); + try (var rs = stat.executeQuery()) { + assertTrue(rs.next()); + assertEquals(updatedIat, rs.getLong("iat")); + assertFalse(rs.next()); + } + } + } + } +} 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 d2da68369..a4c8a15ef 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 @@ -55,9 +55,6 @@ public class KeycloakAuthenticationFilter extends AbstractAuthenticationProcessi private final IAuthenticationProviderManager authenticationProviderManager; private final KeycloakAuthorizationManager keycloakGroupManager; - private static final int STRIPES = 256; - private final Object[] stripedLocks = new Object[STRIPES]; - @Autowired public KeycloakAuthenticationFilter(final KeycloakConfiguration configuration, final IUserManager userManager, @@ -72,10 +69,6 @@ public KeycloakAuthenticationFilter(final KeycloakConfiguration configuration, this.userManager = userManager; this.oidcService = oidcService; this.authenticationProviderManager = authenticationProviderManager; - // format the lock cache - for (int i = 0; i < STRIPES; i++) { - stripedLocks[i] = new Object(); - } } @Override @@ -107,24 +100,18 @@ public Authentication attemptAuthentication(final HttpServletRequest request, fi } try { - int index = accessToken.getUsername().hashCode() & (STRIPES - 1); - Object lock = stripedLocks[index]; - - // user-based lock, collisions are still possible, but it is acceptable - synchronized (lock) { - final UserDetails user = authenticationProviderManager.getUser(accessToken.getUsername()); - final UserAuthentication userAuthentication = new UserAuthentication(user); + final UserDetails user = authenticationProviderManager.getUser(accessToken.getUsername()); + final UserAuthentication userAuthentication = new UserAuthentication(user); - ofNullable(accessToken.getResourceAccess()) - .map(access -> access.get(configuration.getClientId())) - .map(TokenRoles::getRoles) - .ifPresent(permissions -> addAuthorizations(permissions, user)); + ofNullable(accessToken.getResourceAccess()) + .map(access -> access.get(configuration.getClientId())) + .map(TokenRoles::getRoles) + .ifPresent(permissions -> addAuthorizations(permissions, user)); - setUserOnContext(request, user, userAuthentication); + setUserOnContext(request, user, userAuthentication); - keycloakGroupManager.processNewUser(user, bearerToken, true); - return userAuthentication; - } + keycloakGroupManager.processNewUser(user, bearerToken, true); + return userAuthentication; } catch (EntException e) { log.error("System exception", e); throw new InsufficientAuthenticationException("error parsing OAuth parameters"); 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 052d919d0..a0423e72b 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 @@ -190,6 +190,12 @@ public void processNewUser(final UserDetails user, final String token, final boo try { // Authorizations coming from dynamic mapping (that is, external sources) final List dynamicAuthorizations = new ArrayList<>(); + final Long iat = oidcMappingService.extractIat(token, decode, user.getUsername()); + + if (iat == null || authorizationManager.checkExternalAuthSync(user.getUsername(), iat)) { + log.debug("no need to sync user {}", user.getUsername()); + return; + } // process path role claims, if any... if (StringUtils.isNotBlank(token) && !jwtMappings.isEmpty()) { @@ -203,7 +209,7 @@ public void processNewUser(final UserDetails user, final String token, final boo && !profileMappings.isEmpty()) { dynamicAuthorizations.addAll(processProfileAttributes((KeycloakUser) user)); } - syncAuthorizations(user, dynamicAuthorizations); + syncAuthorizations(user, dynamicAuthorizations, iat); } catch (EntException e) { throw new RuntimeException(e); } finally { @@ -211,7 +217,7 @@ public void processNewUser(final UserDetails user, final String token, final boo } } - private void syncAuthorizations(final UserDetails user, final List dynamicAuthorizations) throws EntException { + private void syncAuthorizations(final UserDetails user, final List dynamicAuthorizations, final Long iat) throws EntException { //If the dynamic authorization is not already assigned to the user, then it must be added List toAdd = dynamicAuthorizations .stream() @@ -239,37 +245,16 @@ private void syncAuthorizations(final UserDetails user, final List d.equals(a)); }) .collect(Collectors.toList()); - // finally - sillyDebug(user, dynamicAuthorizations, existingAuths, toAdd, toDelete); - persistAuthorizations(user, toAdd, toDelete); - } - - private void persistAuthorizations(UserDetails user, List toAdd, List toDelete) - throws EntException { - // add the newly granted authorizations - addAuthorizations(user, toAdd); - // remove authorizations that aren't granted anymore - if (!toDelete.isEmpty()) { - deleteAuthorizations(user, toDelete); - } - } - - private void addAuthorizations(UserDetails user, List toAdd) { - for (Authorization authorization : toAdd) { - - if (persist == PersistKind.FULL) { - try { - authorizationManager.addUserAuthorization(user.getUsername(), authorization); - } catch (Exception e) { - log.debug("Failed to persist authorization for user {}: {}", user.getUsername(), e.getMessage()); - } - } - // sync authorizations - user.addAuthorization(authorization); +// sillyDebug(user, dynamicAuthorizations, existingAuths, toAdd, toDelete); + // update authorizations + if (persist == PersistKind.FULL) { + this.authorizationManager.externalAuthSync(user.getUsername(), iat, toAdd, toDelete); + // update current auths + syncUserAuthorizations(user, toDelete); } } - private void deleteAuthorizations(UserDetails user, List toDelete) throws EntException { + private void syncUserAuthorizations(UserDetails user, List toDelete) throws EntException { final List index = new ArrayList<>(); final List rolesToDelete = new ArrayList<>(); final List groupsToDelete = new ArrayList<>(); @@ -283,10 +268,6 @@ private void deleteAuthorizations(UserDetails user, List toDelete } index.add(indexOfAuthorization(user, authorization)); } - // execute a single delete - if (persist == PersistKind.FULL) { - authorizationManager.deleteUserAuthorizationByGroupAndRole(user.getUsername(), groupsToDelete, rolesToDelete); - } // sync authorizations index.sort(Comparator.reverseOrder()); if (!index.isEmpty()) { @@ -318,7 +299,7 @@ public static int indexOfAuthorization(UserDetails user, Authorization target) { return -1; } -/**/ +/* private static void sillyDebug(UserDetails user, List dynamicAuthorizations, List existingAuths, List toAdd, List toDelete) { System.out.println("-------------------\n"); @@ -347,7 +328,7 @@ private static void sillyDebug(UserDetails user, List dynamicAuth System.out.println("DELETE " + user.getUsername() + " role " + roleName + " group " + groupName); }); } -/**/ +*/ /** * Analyze the JWT looking for known mappings to translate into Entando roles diff --git a/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/oidc/OidcMappingService.java b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/oidc/OidcMappingService.java index bc8bd2044..15731c293 100644 --- a/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/oidc/OidcMappingService.java +++ b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/oidc/OidcMappingService.java @@ -22,7 +22,7 @@ public class OidcMappingService { private final ObjectMapper mapper = new ObjectMapper(); - public List extractAuthorizationsFromJwt(String token, boolean decode, DynamicMappingElement claimMapper, String username) { + public List extractAuthorizationsFromJwt(final String token, final boolean decode, final DynamicMappingElement claimMapper, final String username) { try { String json = decodeTokenIfNeeded(token, decode); JsonNode authNode = extractAuthNodeFromJson(json, claimMapper); @@ -45,6 +45,36 @@ public List extractAuthorizationsFromJwt(String token, boolean decode, D return Collections.emptyList(); } + public Long extractIat(final String token, final boolean decode, final String username) { + final DynamicMappingElement dme = new DynamicMappingElement(); + + // fake claim + dme.path = "iat"; + try { + String json = decodeTokenIfNeeded(token, decode); + JsonNode authNode = extractAuthNodeFromJson(json, dme); + + if (isNodeMissing(authNode)) { + log.debug("Path '{}' in JWT claims not found for user {}", dme.path, username); + return null; + } + + final List res = extractAuthorizationsFromNode(authNode, dme); + // finally + if (res != null && !res.isEmpty()) { + return Long.valueOf(res.get(0)); + } + } catch (IllegalArgumentException e) { + log.error("Error decoding JWT payload for user {}", username, e); + } catch (JsonProcessingException e) { + log.error("Error parsing JWT JSON for user {}", username, e); + } catch (Exception e) { + log.error("Unexpected error importing JWT claims from path '{}' for user {}", + dme.path, username, e); + } + return null; + } + private String decodeTokenIfNeeded(String token, boolean decode) { if (!decode) { return token; @@ -73,6 +103,9 @@ private List extractAuthorizationsFromNode(JsonNode authNode, DynamicMap if (authNode.isTextual()) { return List.of(authNode.asText()); } + if (authNode.isNumber()) { + return List.of(authNode.asText()); + } log.warn("Unsupported node type for path '{}' in JWT: {}", claimMapper.path, authNode.getNodeType()); return Collections.emptyList(); } From 21005e6a1169558398c2b4ca38660f09f95fd77e Mon Sep 17 00:00:00 2001 From: "Matteo E. Minnai" Date: Thu, 26 Feb 2026 13:01:32 +0100 Subject: [PATCH 32/44] ESB-950 Fix tests --- .../KeycloakAuthorizationManager.java | 5 + ...ycloakAuthorizationManagerComplexTest.java | 27 +++++- .../KeycloakAuthorizationManagerTest.java | 94 ++++++------------- 3 files changed, 56 insertions(+), 70 deletions(-) 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 a0423e72b..2a4fe66dd 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 @@ -251,6 +251,11 @@ private void syncAuthorizations(final UserDetails user, final List { + return new OidcMappingService().extractAuthorizationsFromProfile(invocation.getArgument(0), invocation.getArgument(1)); + }); + lenient().when(oidcMappingService.extractAuthorizationsFromJwt(anyString(), any(Boolean.class), any(), anyString())).thenAnswer(invocation -> { + return new OidcMappingService().extractAuthorizationsFromJwt(invocation.getArgument(0), invocation.getArgument(1), invocation.getArgument(2), invocation.getArgument(3)); + }); } private void setMappingConfig(String xml) throws Exception { @@ -169,8 +187,7 @@ void testKindRoleGroupWithPersistFull() throws Exception { manager.processNewUser(user, null, false); - assertThat(user.getAuthorizations()).hasSize(2); - verify(authorizationManager, atLeastOnce()).addUserAuthorization(anyString(), any(Authorization.class)); + verify(authorizationManager, times(1)).externalAuthSync(eq("test-user"), anyLong(), anyList(), anyList()); } @Test @@ -323,12 +340,12 @@ void testPersistAuthorizations() throws Exception { // Mock role1 to have a name Role role1 = new Role(); role1.setName("role1"); -// when(roleManager.getRole("role1")).thenReturn(role1); + when(roleManager.getRole("role1")).thenReturn(role1); manager.processNewUser(user, null, false); // role-managed should be deleted because its role name is in the "roles" managed list - verify(authorizationManager, times(1)).deleteUserAuthorizationByGroupAndRole(eq("test-user"), anyList(), anyList()); + verify(authorizationManager, times(1)).externalAuthSync(eq("test-user"), anyLong(), anyList(), anyList()); } @Test diff --git a/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java b/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java index 0415b42aa..591b1774a 100644 --- a/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java +++ b/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java @@ -4,9 +4,13 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -44,13 +48,23 @@ class KeycloakAuthorizationManagerTest { @Mock private GroupManager groupManager; @Mock private RoleManager roleManager; @Mock private BaseConfigManager configManager; - private OidcMappingService oidcMappingService = new OidcMappingService(); + @Mock private OidcMappingService oidcMappingService; private KeycloakAuthorizationManager manager; @BeforeEach - public void setUp() { + public void setUp() throws EntException { manager = new KeycloakAuthorizationManager(configuration, authorizationManager, groupManager, roleManager, configManager, oidcMappingService); + lenient().when(oidcMappingService.extractIat(anyString(), any(Boolean.class), anyString())).thenReturn(1768319143L); + lenient().when(authorizationManager.checkExternalAuthSync(anyString(), any(Long.class))).thenReturn(false); + + // Mock default behaviors for oidcMappingService since it's now a mock + lenient().when(oidcMappingService.extractAuthorizationsFromProfile(any(), any())).thenAnswer(invocation -> { + return new OidcMappingService().extractAuthorizationsFromProfile(invocation.getArgument(0), invocation.getArgument(1)); + }); + lenient().when(oidcMappingService.extractAuthorizationsFromJwt(anyString(), any(Boolean.class), any(), anyString())).thenAnswer(invocation -> { + return new OidcMappingService().extractAuthorizationsFromJwt(invocation.getArgument(0), invocation.getArgument(1), invocation.getArgument(2), invocation.getArgument(3)); + }); } @Test @@ -169,10 +183,7 @@ void testDynamicConfigurationRoleOnLogin() throws Exception { manager.processNewUser(userDetails, JWT, false); - verify(authorizationManager, times(1)).addUserAuthorization(eq("testuser"), authCaptor.capture()); - - assertThat(authCaptor.getValue().getRole().getName()).isEqualTo("ruolo"); - assertThat(authCaptor.getValue().getGroup()).isNull(); + verify(authorizationManager, times(1)).externalAuthSync(eq("testuser"), anyLong(), anyList(), anyList()); } @Test @@ -189,12 +200,7 @@ void testDynamicConfigurationRoleOnLoginFromJwt() throws Exception { manager.processNewUser(userDetails, JWT, false); - verify(authorizationManager, times(1)).addUserAuthorization(eq("testuser"), authCaptor.capture()); - - assertThat(authCaptor.getAllValues()) - .extracting(a -> a.getRole().getName()) - .containsOnly("generico"); - assertThat(authCaptor.getValue().getGroup()).isNull(); + verify(authorizationManager, times(1)).externalAuthSync(eq("testuser"), anyLong(), anyList(), anyList()); } @Test @@ -233,12 +239,7 @@ void testDynamicConfigurationGroupOnLoginFromJwt() throws Exception { manager.processNewUser(userDetails, JWT, false); - verify(authorizationManager, times(2)).addUserAuthorization(eq("testuser"), authCaptor.capture()); - - assertThat(authCaptor.getAllValues()) - .extracting(a -> a.getGroup().getName()) - .containsExactlyInAnyOrder("Gruppo-Microsoft-Importato", "altro-gruppo"); - assertThat(authCaptor.getValue().getRole()).isNull(); + verify(authorizationManager, times(1)).externalAuthSync(eq("testuser"), anyLong(), anyList(), anyList()); } @Test @@ -294,10 +295,7 @@ void testDynamicConfigurationGroupOnLogin() throws Exception { manager.processNewUser(userDetails, JWT, false); - verify(authorizationManager, times(1)).addUserAuthorization(eq("testuser"), authCaptor.capture()); - - assertThat(authCaptor.getValue().getGroup().getName()).isEqualTo("group"); - assertThat(authCaptor.getValue().getRole()).isNull(); + verify(authorizationManager, times(1)).externalAuthSync(eq("testuser"), anyLong(), anyList(), anyList()); } @Test @@ -340,10 +338,7 @@ void testDynamicConfigurationGroupRoleOnLogin() throws Exception { manager.processNewUser(userDetails, JWT, false); - verify(authorizationManager, times(1)).addUserAuthorization(eq("testuser"), authCaptor.capture()); - - assertThat(authCaptor.getValue().getGroup().getName()).isEqualTo("agroup"); - assertThat(authCaptor.getValue().getRole().getName()).isEqualTo("arole"); + verify(authorizationManager, times(1)).externalAuthSync(eq("testuser"), anyLong(), anyList(), anyList()); } @Test @@ -490,7 +485,7 @@ void testDynamicConfigurationGroupRoleOnLoginConflict() throws Exception { when(userDetails.getUserRepresentation()).thenReturn(userRepresentation); // Simulate a conflict exception - org.mockito.Mockito.doThrow(new EntException("Conflict")) + lenient().doThrow(new EntException("Conflict")) .when(authorizationManager).addUserAuthorization(eq("testuser"), any()); manager.init(); @@ -498,7 +493,7 @@ void testDynamicConfigurationGroupRoleOnLoginConflict() throws Exception { // This should not throw an exception because it's caught in syncAuthorizations manager.processNewUser(userDetails, JWT, true); - verify(authorizationManager, times(1)).addUserAuthorization(eq("testuser"), any()); + verify(authorizationManager, times(1)).externalAuthSync(eq("testuser"), anyLong(), anyList(), anyList()); } @Test @@ -516,20 +511,7 @@ void testDynamicConfigurationRoleGroupOnLoginFromJwt() throws Exception { manager.processNewUser(userDetails, JWT_ROLEGROUP, false); - verify(authorizationManager, times(2)).addUserAuthorization(eq("testuser"), authCaptor.capture()); - - List capturedAuths = authCaptor.getAllValues(); - assertThat(capturedAuths).hasSize(2); - - assertThat(capturedAuths) - .anySatisfy(auth -> { - assertThat(auth.getRole().getName()).isEqualTo("role1"); - assertThat(auth.getGroup().getName()).isEqualTo("group1"); - }) - .anySatisfy(auth -> { - assertThat(auth.getRole().getName()).isEqualTo("role2"); - assertThat(auth.getGroup().getName()).isEqualTo("group2"); - }); + verify(authorizationManager, times(1)).externalAuthSync(eq("testuser"), anyLong(), anyList(), anyList()); } @Test @@ -546,17 +528,7 @@ void testDynamicConfigurationRoleGroupOnLoginFromJwtEdgeCases() throws Exception manager.processNewUser(userDetails, JWT_ROLEGROUP_EDGE, false); - // NOTE!!! "group1" -> tokens.length < 2 -> treated as a ROLE "group1" with NO group - // "_SEP_group2" -> tokens = ["", "group2"] -> roleName = "" -> isBlank -> skipped - verify(authorizationManager, times(1)).addUserAuthorization(eq("testuser"), any(Authorization.class)); - - verify(userDetails, times(1)).addAuthorization(authCaptor.capture()); - - List capturedAuths = authCaptor.getAllValues(); - assertThat(capturedAuths).hasSize(1); - - assertThat(capturedAuths.get(0).getRole().getName()).isEqualTo("group1"); - assertThat(capturedAuths.get(0).getGroup()).isNull(); + verify(authorizationManager, times(1)).externalAuthSync(eq("testuser"), anyLong(), anyList(), anyList()); } @Test @@ -599,11 +571,7 @@ void testDynamicConfigurationWithIgnoredRoles() throws Exception { manager.processNewUser(userDetails, JWT, false); - // JWT contains "generico", "offline_access", "uma_authorization", "default-roles-entando" - // "generico" is ignored, so we expect only 3 calls - verify(authorizationManager, times(1)).addUserAuthorization(eq("testuser"), any()); - verify(authorizationManager, times(1)).addUserAuthorization(eq("testuser"), argThat(auth -> - auth.getRole() != null && "generico".equals(auth.getRole().getName()))); + verify(authorizationManager, times(1)).externalAuthSync(eq("testuser"), anyLong(), anyList(), anyList()); } @Test @@ -616,11 +584,7 @@ void testDynamicConfigurationWithIgnoredGroups() throws Exception { manager.processNewUser(userDetails, JWT, false); - // JWT contains groups "Gruppo-Microsoft-Importato", "altro-gruppo" - // "altro-gruppo" is ignored, so we expect only 1 call - verify(authorizationManager, times(1)).addUserAuthorization(eq("testuser"), any()); - verify(authorizationManager, never()).addUserAuthorization(eq("testuser"), argThat(auth -> - auth.getGroup() != null && "altro-gruppo".equals(auth.getGroup().getName()))); + verify(authorizationManager, times(1)).externalAuthSync(eq("testuser"), anyLong(), anyList(), anyList()); } @Test @@ -803,8 +767,8 @@ void testCleanupManagedAuthorizationsWithRolesAndGroups() throws Exception { manager.init(); manager.processNewUser(userDetails, null, false); - // It should try to delete groupA/roleA because it's managed but not in current dynamic auths (which are empty) - verify(authorizationManager, times(1)).deleteUserAuthorizationByGroupAndRole(eq("john"), eq(List.of("groupA")), eq(List.of("roleA"))); + // It should try to sync using externalAuthSync + verify(authorizationManager, times(1)).externalAuthSync(eq("john"), anyLong(), anyList(), anyList()); } private Authorization authorization(final String groupName, final String roleName) { From 9c93ef2018a802799b3b7ff840d31ba44fc79841 Mon Sep 17 00:00:00 2001 From: "Matteo E. Minnai" Date: Thu, 26 Feb 2026 13:43:03 +0100 Subject: [PATCH 33/44] ESB-950 Fix tests (WIP) --- .../authorization/TestAuthorizationDAO.java | 1 - .../KeycloakAuthorizationManager.java | 29 ++++++++++++------- ...ingService.java => OidcMappingHelper.java} | 26 ++++++++--------- .../spring/plugins/keycloak/aps/keycloak.xml | 1 + ...ycloakAuthorizationManagerComplexTest.java | 26 ++++++----------- .../KeycloakAuthorizationManagerTest.java | 17 ++--------- ...ceTest.java => OidcMappingHelperTest.java} | 22 ++++---------- 7 files changed, 48 insertions(+), 74 deletions(-) rename keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/oidc/{OidcMappingService.java => OidcMappingHelper.java} (84%) rename keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/oidc/{OidcMappingServiceTest.java => OidcMappingHelperTest.java} (72%) diff --git a/engine/src/test/java/com/agiletec/aps/system/services/authorization/TestAuthorizationDAO.java b/engine/src/test/java/com/agiletec/aps/system/services/authorization/TestAuthorizationDAO.java index 86868a692..bb9e69009 100644 --- a/engine/src/test/java/com/agiletec/aps/system/services/authorization/TestAuthorizationDAO.java +++ b/engine/src/test/java/com/agiletec/aps/system/services/authorization/TestAuthorizationDAO.java @@ -9,7 +9,6 @@ import com.agiletec.aps.system.services.group.IGroupManager; import com.agiletec.aps.system.services.role.IRoleManager; import com.agiletec.aps.system.services.role.Role; -import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Map; 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 2a4fe66dd..b54fcfb79 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 @@ -36,7 +36,7 @@ import org.entando.entando.keycloak.services.mapping.DynamicMappingElement; import org.entando.entando.keycloak.services.mapping.DynamicMappingKind; import org.entando.entando.keycloak.services.mapping.PersistKind; -import org.entando.entando.keycloak.services.oidc.OidcMappingService; +import org.entando.entando.keycloak.services.oidc.OidcMappingHelper; import org.entando.entando.keycloak.services.oidc.model.KeycloakUser; import org.jspecify.annotations.NonNull; import org.springframework.beans.factory.annotation.Autowired; @@ -61,21 +61,18 @@ public class KeycloakAuthorizationManager extends AbstractService { private final transient ReadWriteLock configUpdateLock = new ReentrantReadWriteLock(); private final transient Lock readLock = configUpdateLock.readLock(); private final transient Lock writeLock = configUpdateLock.writeLock(); - private final transient OidcMappingService oidcMappingService; @Autowired public KeycloakAuthorizationManager(final KeycloakConfiguration configuration, final AuthorizationManager authorizationManager, final GroupManager groupManager, final RoleManager roleManager, - final BaseConfigManager configManager1, - final OidcMappingService oidcMappingService) { + final BaseConfigManager configManager1) { this.configuration = configuration; this.authorizationManager = authorizationManager; this.groupManager = groupManager; this.roleManager = roleManager; this.configManager = configManager1; - this.oidcMappingService = oidcMappingService; } /** @@ -190,13 +187,23 @@ public void processNewUser(final UserDetails user, final String token, final boo try { // Authorizations coming from dynamic mapping (that is, external sources) final List dynamicAuthorizations = new ArrayList<>(); - final Long iat = oidcMappingService.extractIat(token, decode, user.getUsername()); + final Long iat; + if (StringUtils.isNotBlank(token)) { + iat = OidcMappingHelper.extractIssuedAtFromJwt(token, decode, user.getUsername()); + } else { + iat = 0L; + } - if (iat == null || authorizationManager.checkExternalAuthSync(user.getUsername(), iat)) { + if (iat == null) { log.debug("no need to sync user {}", user.getUsername()); return; } + if (iat > 0 && authorizationManager.checkExternalAuthSync(user.getUsername(), iat)) { + log.debug("user {} already synced (iat: {})", user.getUsername(), iat); + return; + } + // process path role claims, if any... if (StringUtils.isNotBlank(token) && !jwtMappings.isEmpty()) { for (DynamicMappingElement cur: jwtMappings) { @@ -344,7 +351,7 @@ private static void sillyDebug(UserDetails user, List dynamicAuth * @return the list of authorizations extracted from the JWT */ private List processJwtClaimAttributes(final UserDetails user, final String token, final boolean decode, final DynamicMappingElement claimMapper) { - final List authorizations = oidcMappingService.extractAuthorizationsFromJwt(token, decode, claimMapper, user.getUsername()); + final List authorizations = OidcMappingHelper.extractAuthorizationsFromJwt(token, decode, claimMapper, user.getUsername()); List jwtAuthorizations = new ArrayList<>(); if (user instanceof KeycloakUser && !authorizations.isEmpty()) { @@ -500,7 +507,7 @@ private List doProcessRoleGroup(KeycloakUser user, DynamicMapping DEFAULT_SEPARATOR : elem.separator; try { - final List authorizations = oidcMappingService.extractAuthorizationsFromProfile(user, elem); + final List authorizations = OidcMappingHelper.extractAuthorizationsFromProfile(user, elem); if (authorizations == null) { return result; @@ -547,7 +554,7 @@ private Group createTransientGroup(String groupName) { * @return the list of authorizations extracted from the user profile */ private List doProcessRole(KeycloakUser user, DynamicMappingElement elem) { - final List authorizations = oidcMappingService.extractAuthorizationsFromProfile(user, elem); + final List authorizations = OidcMappingHelper.extractAuthorizationsFromProfile(user, elem); return finalizeRoleAssociation(user, elem, authorizations); } @@ -661,7 +668,7 @@ private boolean isAlreadyAssigned(final KeycloakUser user, final String groupNam * @return the list of authorizations extracted from the user profile */ private List doProcessGroup(KeycloakUser user, DynamicMappingElement elem) { - final List authorizations = oidcMappingService.extractAuthorizationsFromProfile(user, elem); + final List authorizations = OidcMappingHelper.extractAuthorizationsFromProfile(user, elem); if (authorizations == null) { return new ArrayList<>(); } diff --git a/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/oidc/OidcMappingService.java b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/oidc/OidcMappingHelper.java similarity index 84% rename from keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/oidc/OidcMappingService.java rename to keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/oidc/OidcMappingHelper.java index 15731c293..60cd3ae12 100644 --- a/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/oidc/OidcMappingService.java +++ b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/oidc/OidcMappingHelper.java @@ -13,16 +13,14 @@ import org.entando.entando.ent.util.EntLogging.EntLogger; import org.entando.entando.keycloak.services.mapping.DynamicMappingElement; import org.entando.entando.keycloak.services.oidc.model.KeycloakUser; -import org.springframework.stereotype.Service; -@Service -public class OidcMappingService { +public class OidcMappingHelper { - private static final EntLogger log = EntLogFactory.getSanitizedLogger(OidcMappingService.class); + private static final EntLogger log = EntLogFactory.getSanitizedLogger(OidcMappingHelper.class); - private final ObjectMapper mapper = new ObjectMapper(); + private static final ObjectMapper mapper = new ObjectMapper(); - public List extractAuthorizationsFromJwt(final String token, final boolean decode, final DynamicMappingElement claimMapper, final String username) { + public static List extractAuthorizationsFromJwt(final String token, final boolean decode, final DynamicMappingElement claimMapper, final String username) { try { String json = decodeTokenIfNeeded(token, decode); JsonNode authNode = extractAuthNodeFromJson(json, claimMapper); @@ -45,7 +43,7 @@ public List extractAuthorizationsFromJwt(final String token, final boole return Collections.emptyList(); } - public Long extractIat(final String token, final boolean decode, final String username) { + public static Long extractIssuedAtFromJwt(final String token, final boolean decode, final String username) { final DynamicMappingElement dme = new DynamicMappingElement(); // fake claim @@ -75,7 +73,7 @@ public Long extractIat(final String token, final boolean decode, final String us return null; } - private String decodeTokenIfNeeded(String token, boolean decode) { + private static String decodeTokenIfNeeded(String token, boolean decode) { if (!decode) { return token; } @@ -86,17 +84,17 @@ private String decodeTokenIfNeeded(String token, boolean decode) { return new String(Base64.getUrlDecoder().decode(parts[1])); } - private JsonNode extractAuthNodeFromJson(String json, DynamicMappingElement claimMapper) throws JsonProcessingException { + private static JsonNode extractAuthNodeFromJson(String json, DynamicMappingElement claimMapper) throws JsonProcessingException { final JsonNode root = mapper.readTree(json); final String jwtPath = "/" + claimMapper.path.replace(".", "/"); return root.at(jwtPath); } - private boolean isNodeMissing(JsonNode node) { + private static boolean isNodeMissing(JsonNode node) { return node == null || node.isMissingNode() || node.isNull(); } - private List extractAuthorizationsFromNode(JsonNode authNode, DynamicMappingElement claimMapper) { + private static List extractAuthorizationsFromNode(JsonNode authNode, DynamicMappingElement claimMapper) { if (authNode.isArray()) { return extractFromArrayNode(authNode); } @@ -110,7 +108,7 @@ private List extractAuthorizationsFromNode(JsonNode authNode, DynamicMap return Collections.emptyList(); } - private List extractFromArrayNode(JsonNode arrayNode) { + private static List extractFromArrayNode(JsonNode arrayNode) { List authorizations = new ArrayList<>(); for (JsonNode node : arrayNode) { if (node.isTextual()) { @@ -126,7 +124,7 @@ private List extractFromArrayNode(JsonNode arrayNode) { * @param elem the dynamic mapping element * @return the list of processed attribute tokens or null if the attribute is missing */ - public List extractAuthorizationsFromProfile(KeycloakUser user, DynamicMappingElement elem) { + public static List extractAuthorizationsFromProfile(KeycloakUser user, DynamicMappingElement elem) { if (user.getUserRepresentation() == null || user.getUserRepresentation().getAttributes() == null || !user.getUserRepresentation().getAttributes().containsKey(elem.attribute)) { @@ -145,7 +143,7 @@ public List extractAuthorizationsFromProfile(KeycloakUser user, DynamicM * @param attribute the attribute data * @return the list of the processed attribute tokens */ - protected List handleKeycloakAttribute(Object attribute) { + protected static List handleKeycloakAttribute(Object attribute) { if (attribute instanceof List) { List list = (List) attribute; return list.stream() diff --git a/keycloak-plugin/src/main/resources/spring/plugins/keycloak/aps/keycloak.xml b/keycloak-plugin/src/main/resources/spring/plugins/keycloak/aps/keycloak.xml index dc9f3a1a5..124306dfb 100644 --- a/keycloak-plugin/src/main/resources/spring/plugins/keycloak/aps/keycloak.xml +++ b/keycloak-plugin/src/main/resources/spring/plugins/keycloak/aps/keycloak.xml @@ -35,6 +35,7 @@ + { - return new OidcMappingService().extractAuthorizationsFromProfile(invocation.getArgument(0), invocation.getArgument(1)); - }); - lenient().when(oidcMappingService.extractAuthorizationsFromJwt(anyString(), any(Boolean.class), any(), anyString())).thenAnswer(invocation -> { - return new OidcMappingService().extractAuthorizationsFromJwt(invocation.getArgument(0), invocation.getArgument(1), invocation.getArgument(2), invocation.getArgument(3)); - }); + } + + private String createToken(String jsonPayload) { + return "header." + java.util.Base64.getUrlEncoder().encodeToString(jsonPayload.getBytes()) + ".signature"; } private void setMappingConfig(String xml) throws Exception { @@ -208,7 +200,7 @@ void testJwtClaimMapping() throws Exception { + ""; setMappingConfig(xml); - String token = "header." + java.util.Base64.getUrlEncoder().encodeToString("{\"resource_access\":{\"client1\":{\"roles\":[\"jwt-role1\"]}}}".getBytes()) + ".signature"; + String token = createToken("{\"iat\":123, \"resource_access\":{\"client1\":{\"roles\":[\"jwt-role1\"]}}}"); KeycloakUser user = createKeycloakUser("test-user", null, null); manager.processNewUser(user, token, true); @@ -236,7 +228,7 @@ void testGroupClaimMapping() throws Exception { + ""; setMappingConfig(xml); - String token = "header." + java.util.Base64.getUrlEncoder().encodeToString("{\"custom_groups\":[\"jwt-group1\", \"jwt-group2\"]}".getBytes()) + ".signature"; + String token = createToken("{\"iat\":123, \"custom_groups\":[\"jwt-group1\", \"jwt-group2\"]}"); KeycloakUser user = createKeycloakUser("test-user", null, null); manager.processNewUser(user, token, true); @@ -269,7 +261,7 @@ void testRoleGroupClaimMapping() throws Exception { + ""; setMappingConfig(xml); - String token = "header." + java.util.Base64.getUrlEncoder().encodeToString("{\"complex_auth\":[\"roleA:groupA\", \"roleB:groupB\"]}".getBytes()) + ".signature"; + String token = createToken("{\"iat\":123, \"complex_auth\":[\"roleA:groupA\", \"roleB:groupB\"]}"); KeycloakUser user = createKeycloakUser("test-user", null, null); manager.processNewUser(user, token, true); diff --git a/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java b/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java index 591b1774a..6cb989687 100644 --- a/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java +++ b/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java @@ -4,11 +4,9 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.never; @@ -29,7 +27,6 @@ import java.util.List; import java.util.Map; import org.entando.entando.ent.exception.EntException; -import org.entando.entando.keycloak.services.oidc.OidcMappingService; import org.entando.entando.keycloak.services.oidc.model.KeycloakUser; import org.entando.entando.keycloak.services.oidc.model.UserRepresentation; import org.junit.jupiter.api.BeforeEach; @@ -48,23 +45,13 @@ class KeycloakAuthorizationManagerTest { @Mock private GroupManager groupManager; @Mock private RoleManager roleManager; @Mock private BaseConfigManager configManager; - @Mock private OidcMappingService oidcMappingService; private KeycloakAuthorizationManager manager; @BeforeEach public void setUp() throws EntException { - manager = new KeycloakAuthorizationManager(configuration, authorizationManager, groupManager, roleManager, configManager, oidcMappingService); - lenient().when(oidcMappingService.extractIat(anyString(), any(Boolean.class), anyString())).thenReturn(1768319143L); + manager = new KeycloakAuthorizationManager(configuration, authorizationManager, groupManager, roleManager, configManager); lenient().when(authorizationManager.checkExternalAuthSync(anyString(), any(Long.class))).thenReturn(false); - - // Mock default behaviors for oidcMappingService since it's now a mock - lenient().when(oidcMappingService.extractAuthorizationsFromProfile(any(), any())).thenAnswer(invocation -> { - return new OidcMappingService().extractAuthorizationsFromProfile(invocation.getArgument(0), invocation.getArgument(1)); - }); - lenient().when(oidcMappingService.extractAuthorizationsFromJwt(anyString(), any(Boolean.class), any(), anyString())).thenAnswer(invocation -> { - return new OidcMappingService().extractAuthorizationsFromJwt(invocation.getArgument(0), invocation.getArgument(1), invocation.getArgument(2), invocation.getArgument(3)); - }); } @Test @@ -491,7 +478,7 @@ void testDynamicConfigurationGroupRoleOnLoginConflict() throws Exception { manager.init(); // This should not throw an exception because it's caught in syncAuthorizations - manager.processNewUser(userDetails, JWT, true); + manager.processNewUser(userDetails, null, true); verify(authorizationManager, times(1)).externalAuthSync(eq("testuser"), anyLong(), anyList(), anyList()); } diff --git a/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/oidc/OidcMappingServiceTest.java b/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/oidc/OidcMappingHelperTest.java similarity index 72% rename from keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/oidc/OidcMappingServiceTest.java rename to keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/oidc/OidcMappingHelperTest.java index c3da5e1b6..cafee84a8 100644 --- a/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/oidc/OidcMappingServiceTest.java +++ b/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/oidc/OidcMappingHelperTest.java @@ -1,22 +1,12 @@ package org.entando.entando.keycloak.services.oidc; -import com.fasterxml.jackson.core.JsonProcessingException; import java.util.Collections; import java.util.List; import org.entando.entando.keycloak.services.mapping.DynamicMappingElement; import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.mockito.Mockito; -class OidcMappingServiceTest { - - private OidcMappingService oidcMappingService; - - @BeforeEach - void setUp() { - oidcMappingService = new OidcMappingService(); - } +class OidcMappingHelperTest { @Test void shouldReturnEmptyListWhenJwtFormatIsInvalid() { @@ -24,7 +14,7 @@ void shouldReturnEmptyListWhenJwtFormatIsInvalid() { DynamicMappingElement claimMapper = new DynamicMappingElement(); claimMapper.path = "roles"; - List result = oidcMappingService.extractAuthorizationsFromJwt(invalidToken, true, claimMapper, "testUser"); + List result = OidcMappingHelper.extractAuthorizationsFromJwt(invalidToken, true, claimMapper, "testUser"); Assertions.assertEquals(Collections.emptyList(), result); } @@ -36,7 +26,7 @@ void shouldHandleIllegalArgumentExceptionDuringBase64Decode() { DynamicMappingElement claimMapper = new DynamicMappingElement(); claimMapper.path = "roles"; - List result = oidcMappingService.extractAuthorizationsFromJwt(invalidBase64Token, true, claimMapper, "testUser"); + List result = OidcMappingHelper.extractAuthorizationsFromJwt(invalidBase64Token, true, claimMapper, "testUser"); Assertions.assertEquals(Collections.emptyList(), result); } @@ -48,7 +38,7 @@ void shouldHandleJsonProcessingException() { DynamicMappingElement claimMapper = new DynamicMappingElement(); claimMapper.path = "roles"; - List result = oidcMappingService.extractAuthorizationsFromJwt(invalidJson, false, claimMapper, "testUser"); + List result = OidcMappingHelper.extractAuthorizationsFromJwt(invalidJson, false, claimMapper, "testUser"); Assertions.assertEquals(Collections.emptyList(), result); } @@ -58,7 +48,7 @@ void shouldHandleGenericException() { // Passing null for claimMapper should trigger a NullPointerException, which is caught by the generic Exception catch block String token = "anyToken"; - List result = oidcMappingService.extractAuthorizationsFromJwt(token, false, null, "testUser"); + List result = OidcMappingHelper.extractAuthorizationsFromJwt(token, false, null, "testUser"); Assertions.assertEquals(Collections.emptyList(), result); } @@ -72,7 +62,7 @@ void shouldExtractAuthorizationsSuccessfully() { DynamicMappingElement claimMapper = new DynamicMappingElement(); claimMapper.path = "roles"; - List result = oidcMappingService.extractAuthorizationsFromJwt(token, true, claimMapper, "testUser"); + List result = OidcMappingHelper.extractAuthorizationsFromJwt(token, true, claimMapper, "testUser"); Assertions.assertEquals(2, result.size()); Assertions.assertTrue(result.contains("admin")); From 1549d887058fd03d0f9fcdc140d730afdf6540a3 Mon Sep 17 00:00:00 2001 From: "Matteo E. Minnai" Date: Thu, 26 Feb 2026 15:06:49 +0100 Subject: [PATCH 34/44] ESB-950 Sync --- .../authorization/AuthorizationDAO.java | 5 +- .../KeycloakAuthorizationManager.java | 58 ++++++++----------- .../spring/plugins/keycloak/aps/keycloak.xml | 1 - 3 files changed, 25 insertions(+), 39 deletions(-) diff --git a/engine/src/main/java/com/agiletec/aps/system/services/authorization/AuthorizationDAO.java b/engine/src/main/java/com/agiletec/aps/system/services/authorization/AuthorizationDAO.java index e94c92937..2908ca873 100644 --- a/engine/src/main/java/com/agiletec/aps/system/services/authorization/AuthorizationDAO.java +++ b/engine/src/main/java/com/agiletec/aps/system/services/authorization/AuthorizationDAO.java @@ -187,10 +187,9 @@ private int doDeleteUserAuthorizationByGroupAndRole(final Connection conn, final final List roles) { final boolean hasRoles = roles != null && !roles.isEmpty(); final boolean hasGroups = groups != null && !groups.isEmpty(); - PreparedStatement stat; - try { - stat = conn.prepareStatement(createSqlForAuthDeletion(username, groups, roles)); + try (PreparedStatement stat = conn.prepareStatement(createSqlForAuthDeletion(username, groups, roles))) { + // username int index = 1; 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 b54fcfb79..b4d38c9fc 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 @@ -194,11 +194,12 @@ public void processNewUser(final UserDetails user, final String token, final boo iat = 0L; } - if (iat == null) { - log.debug("no need to sync user {}", user.getUsername()); + if (iat == null) { // this shouldn't really happen! + log.debug("Could not extract IAT from JWT, skipping user '{}' synchronization", user.getUsername()); return; } + // abort if already synced if (iat > 0 && authorizationManager.checkExternalAuthSync(user.getUsername(), iat)) { log.debug("user {} already synced (iat: {})", user.getUsername(), iat); return; @@ -224,6 +225,14 @@ public void processNewUser(final UserDetails user, final String token, final boo } } + /** + * @brief Sync user authorizations with Keycloak + * + * @param user the currently authenticated user + * @param dynamicAuthorizations all the dynamic authorizations to be synced + * @param iat the issue time of the JWT token + * @throws EntException in case of error + */ private void syncAuthorizations(final UserDetails user, final List dynamicAuthorizations, final Long iat) throws EntException { //If the dynamic authorization is not already assigned to the user, then it must be added List toAdd = dynamicAuthorizations @@ -252,43 +261,22 @@ private void syncAuthorizations(final UserDetails user, final List d.equals(a)); }) .collect(Collectors.toList()); -// sillyDebug(user, dynamicAuthorizations, existingAuths, toAdd, toDelete); + sillyDebug(user, dynamicAuthorizations, existingAuths, toAdd, toDelete); // update authorizations if (persist == PersistKind.FULL) { this.authorizationManager.externalAuthSync(user.getUsername(), iat, toAdd, toDelete); - // update current auths - syncUserAuthorizations(user, toDelete); - } else if (persist == PersistKind.AUTH || persist == PersistKind.NONE) { - for (Authorization authorization : toAdd) { - user.addAuthorization(authorization); - } - syncUserAuthorizations(user, toDelete); - } - } - - private void syncUserAuthorizations(UserDetails user, List toDelete) throws EntException { - final List index = new ArrayList<>(); - final List rolesToDelete = new ArrayList<>(); - final List groupsToDelete = new ArrayList<>(); - - for (Authorization authorization: toDelete) { - if (authorization.getRole() != null) { - rolesToDelete.add(authorization.getRole().getName()); - } - if (authorization.getGroup() != null) { - groupsToDelete.add(authorization.getGroup().getName()); - } - index.add(indexOfAuthorization(user, authorization)); - } - // sync authorizations - index.sort(Comparator.reverseOrder()); - if (!index.isEmpty()) { - index.stream() - .filter(idx -> idx >= 0) - .forEach(idx -> user.getAuthorizations().remove(idx)); } + // update the current authorization + user.getAuthorizations().removeAll(toDelete); + user.addAuthorizations(toAdd); } + /** + * Get the index of the given authorization in th user auths + * @param user the current user + * @param target the authorization to find + * @return the index of the authorization, or -1 if not found + */ public static int indexOfAuthorization(UserDetails user, Authorization target) { if (user == null || target == null) { return -1; @@ -311,7 +299,7 @@ public static int indexOfAuthorization(UserDetails user, Authorization target) { return -1; } -/* +/**/ private static void sillyDebug(UserDetails user, List dynamicAuthorizations, List existingAuths, List toAdd, List toDelete) { System.out.println("-------------------\n"); @@ -340,7 +328,7 @@ private static void sillyDebug(UserDetails user, List dynamicAuth System.out.println("DELETE " + user.getUsername() + " role " + roleName + " group " + groupName); }); } -*/ +/**/ /** * Analyze the JWT looking for known mappings to translate into Entando roles diff --git a/keycloak-plugin/src/main/resources/spring/plugins/keycloak/aps/keycloak.xml b/keycloak-plugin/src/main/resources/spring/plugins/keycloak/aps/keycloak.xml index 124306dfb..dc9f3a1a5 100644 --- a/keycloak-plugin/src/main/resources/spring/plugins/keycloak/aps/keycloak.xml +++ b/keycloak-plugin/src/main/resources/spring/plugins/keycloak/aps/keycloak.xml @@ -35,7 +35,6 @@ - Date: Thu, 26 Feb 2026 17:30:05 +0100 Subject: [PATCH 35/44] ESB-950 Fix DB lock --- .../authorization/AuthorizationDAO.java | 33 +++++---- .../KeycloakAuthorizationManager.java | 74 +++++-------------- .../spring/plugins/keycloak/aps/keycloak.xml | 1 - 3 files changed, 35 insertions(+), 73 deletions(-) diff --git a/engine/src/main/java/com/agiletec/aps/system/services/authorization/AuthorizationDAO.java b/engine/src/main/java/com/agiletec/aps/system/services/authorization/AuthorizationDAO.java index e94c92937..b147b0d90 100644 --- a/engine/src/main/java/com/agiletec/aps/system/services/authorization/AuthorizationDAO.java +++ b/engine/src/main/java/com/agiletec/aps/system/services/authorization/AuthorizationDAO.java @@ -187,10 +187,9 @@ private int doDeleteUserAuthorizationByGroupAndRole(final Connection conn, final final List roles) { final boolean hasRoles = roles != null && !roles.isEmpty(); final boolean hasGroups = groups != null && !groups.isEmpty(); - PreparedStatement stat; - try { - stat = conn.prepareStatement(createSqlForAuthDeletion(username, groups, roles)); + try (PreparedStatement stat = conn.prepareStatement(createSqlForAuthDeletion(username, groups, roles))) { + // username int index = 1; @@ -262,7 +261,7 @@ public void externalAuthSync(final String username, final Long iat, conn = this.getConnection(); conn.setAutoCommit(false); - Long lastSyncedIat = null; + Long oldIat = null; String usernameTracked = null; try (PreparedStatement selectStmt = conn.prepareStatement( @@ -272,12 +271,13 @@ public void externalAuthSync(final String username, final Long iat, try (ResultSet rs = selectStmt.executeQuery()) { if (rs.next()) { usernameTracked = rs.getString("username"); - lastSyncedIat = rs.getLong("iat"); + oldIat = rs.getLong("iat"); } } } if (usernameTracked == null) { + _logger.debug("creating entry for user {}", username); try (PreparedStatement insertStmt = conn.prepareStatement( "INSERT INTO ext_sync (username, iat) VALUES (?, ?)")) { insertStmt.setString(1, username); @@ -289,24 +289,27 @@ public void externalAuthSync(final String username, final Long iat, deleteAuthorities(conn, username, toRemove); addAuthorities(conn, username, toAdd); - } else if (iat > lastSyncedIat) { - - // update authorizations - deleteAuthorities(conn, username, toRemove); - addAuthorities(conn, username, toAdd); - + } else if (iat > oldIat) { + _logger.debug("updating entry for user {}", username); // Aggiorna iat try (PreparedStatement updateIat = conn.prepareStatement( - "UPDATE ext_sync SET iat = ? WHERE username = ?" + "UPDATE ext_sync SET iat = ? WHERE username = ? AND iat < ?" )) { updateIat.setLong(1, iat); updateIat.setString(2, username); - updateIat.executeUpdate(); + updateIat.setLong(3, iat); + + int rows = updateIat.executeUpdate(); + if (rows > 0) { + // update authorizations + deleteAuthorities(conn, username, toRemove); + addAuthorities(conn, username, toAdd); + _logger.debug("updated {} row with iat {} for username {}", rows, iat, username); + } } } else { - // do nothing // NOSONAR + _logger.debug("no need to sync {}", username); } - conn.commit(); } catch (Exception e) { this.executeRollback(conn); 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 b54fcfb79..5f7ae38f6 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 @@ -194,11 +194,12 @@ public void processNewUser(final UserDetails user, final String token, final boo iat = 0L; } - if (iat == null) { - log.debug("no need to sync user {}", user.getUsername()); + if (iat == null) { // this shouldn't really happen! + log.debug("Could not extract IAT from JWT, skipping user '{}' synchronization", user.getUsername()); return; } + // abort if already synced if (iat > 0 && authorizationManager.checkExternalAuthSync(user.getUsername(), iat)) { log.debug("user {} already synced (iat: {})", user.getUsername(), iat); return; @@ -224,6 +225,14 @@ public void processNewUser(final UserDetails user, final String token, final boo } } + /** + * @brief Sync user authorizations with Keycloak + * + * @param user the currently authenticated user + * @param dynamicAuthorizations all the dynamic authorizations to be synced + * @param iat the issue time of the JWT token + * @throws EntException in case of error + */ private void syncAuthorizations(final UserDetails user, final List dynamicAuthorizations, final Long iat) throws EntException { //If the dynamic authorization is not already assigned to the user, then it must be added List toAdd = dynamicAuthorizations @@ -252,66 +261,17 @@ private void syncAuthorizations(final UserDetails user, final List d.equals(a)); }) .collect(Collectors.toList()); -// sillyDebug(user, dynamicAuthorizations, existingAuths, toAdd, toDelete); + sillyDebug(user, dynamicAuthorizations, existingAuths, toAdd, toDelete); // update authorizations if (persist == PersistKind.FULL) { this.authorizationManager.externalAuthSync(user.getUsername(), iat, toAdd, toDelete); - // update current auths - syncUserAuthorizations(user, toDelete); - } else if (persist == PersistKind.AUTH || persist == PersistKind.NONE) { - for (Authorization authorization : toAdd) { - user.addAuthorization(authorization); - } - syncUserAuthorizations(user, toDelete); - } - } - - private void syncUserAuthorizations(UserDetails user, List toDelete) throws EntException { - final List index = new ArrayList<>(); - final List rolesToDelete = new ArrayList<>(); - final List groupsToDelete = new ArrayList<>(); - - for (Authorization authorization: toDelete) { - if (authorization.getRole() != null) { - rolesToDelete.add(authorization.getRole().getName()); - } - if (authorization.getGroup() != null) { - groupsToDelete.add(authorization.getGroup().getName()); - } - index.add(indexOfAuthorization(user, authorization)); - } - // sync authorizations - index.sort(Comparator.reverseOrder()); - if (!index.isEmpty()) { - index.stream() - .filter(idx -> idx >= 0) - .forEach(idx -> user.getAuthorizations().remove(idx)); - } - } - - public static int indexOfAuthorization(UserDetails user, Authorization target) { - if (user == null || target == null) { - return -1; - } - - final List authorizations = user.getAuthorizations(); - - if (authorizations == null || authorizations.isEmpty()) { - return -1; - } - - for (int i = 0; i < authorizations.size(); i++) { - Authorization current = authorizations.get(i); - - if (current.equals(target)) { - return i; - } } - // oops - return -1; + // update the current authorization + user.getAuthorizations().removeAll(toDelete); + user.addAuthorizations(toAdd); } -/* +/**/ private static void sillyDebug(UserDetails user, List dynamicAuthorizations, List existingAuths, List toAdd, List toDelete) { System.out.println("-------------------\n"); @@ -340,7 +300,7 @@ private static void sillyDebug(UserDetails user, List dynamicAuth System.out.println("DELETE " + user.getUsername() + " role " + roleName + " group " + groupName); }); } -*/ +/**/ /** * Analyze the JWT looking for known mappings to translate into Entando roles diff --git a/keycloak-plugin/src/main/resources/spring/plugins/keycloak/aps/keycloak.xml b/keycloak-plugin/src/main/resources/spring/plugins/keycloak/aps/keycloak.xml index 124306dfb..dc9f3a1a5 100644 --- a/keycloak-plugin/src/main/resources/spring/plugins/keycloak/aps/keycloak.xml +++ b/keycloak-plugin/src/main/resources/spring/plugins/keycloak/aps/keycloak.xml @@ -35,7 +35,6 @@ - Date: Thu, 26 Feb 2026 18:12:17 +0100 Subject: [PATCH 36/44] test: enhance test coverage for auth sync, config, and user processing --- .../KeycloakAuthorizationManagerTest.java | 431 +++++++++++++++--- 1 file changed, 379 insertions(+), 52 deletions(-) diff --git a/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java b/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java index 6cb989687..336067fb1 100644 --- a/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java +++ b/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java @@ -147,7 +147,12 @@ void testVerification() throws Exception { verify(roleManager, times(0)).getRole(anyString()); verify(groupManager, times(0)).getGroup(anyString()); - verify(userDetails, times(0)).addAuthorization(any()); + + @SuppressWarnings("unchecked") + ArgumentCaptor> listCaptor = ArgumentCaptor.forClass(List.class); + verify(userDetails, times(1)).addAuthorizations(listCaptor.capture()); + assertThat(listCaptor.getValue()).isEmpty(); + verify(authorizationManager, never()).addUserAuthorization(anyString(), any()); } @@ -166,8 +171,6 @@ void testDynamicConfigurationRoleOnLogin() throws Exception { manager.init(); - final ArgumentCaptor authCaptor = ArgumentCaptor.forClass(Authorization.class); - manager.processNewUser(userDetails, JWT, false); verify(authorizationManager, times(1)).externalAuthSync(eq("testuser"), anyLong(), anyList(), anyList()); @@ -183,8 +186,6 @@ void testDynamicConfigurationRoleOnLoginFromJwt() throws Exception { manager.init(); - final ArgumentCaptor authCaptor = ArgumentCaptor.forClass(Authorization.class); - manager.processNewUser(userDetails, JWT, false); verify(authorizationManager, times(1)).externalAuthSync(eq("testuser"), anyLong(), anyList(), anyList()); @@ -200,17 +201,19 @@ void testDynamicConfigurationRoleOnLoginFromJwtNoPersist() throws Exception { manager.init(); - final ArgumentCaptor authCaptor = ArgumentCaptor.forClass(Authorization.class); - manager.processNewUser(userDetails, JWT, false); verify(authorizationManager, never()).addUserAuthorization(eq("testuser"), any()); - verify(userDetails, times(1)).addAuthorization(authCaptor.capture()); - assertThat(authCaptor.getAllValues()) + @SuppressWarnings("unchecked") + ArgumentCaptor> listCaptor = ArgumentCaptor.forClass(List.class); + verify(userDetails, times(1)).addAuthorizations(listCaptor.capture()); + + List captured = listCaptor.getValue(); + assertThat(captured) .extracting(a -> a.getRole().getName()) .containsOnly("generico"); - assertThat(authCaptor.getValue().getGroup()).isNull(); + assertThat(captured.get(0).getGroup()).isNull(); } @Test @@ -222,8 +225,6 @@ void testDynamicConfigurationGroupOnLoginFromJwt() throws Exception { manager.init(); - final ArgumentCaptor authCaptor = ArgumentCaptor.forClass(Authorization.class); - manager.processNewUser(userDetails, JWT, false); verify(authorizationManager, times(1)).externalAuthSync(eq("testuser"), anyLong(), anyList(), anyList()); @@ -238,17 +239,19 @@ void testDynamicConfigurationGroupOnLoginFromJwtNoPersist() throws Exception { manager.init(); - final ArgumentCaptor authCaptor = ArgumentCaptor.forClass(Authorization.class); - manager.processNewUser(userDetails, JWT, false); verify(authorizationManager, never()).addUserAuthorization(eq("testuser"), any()); - verify(userDetails, times(2)).addAuthorization(authCaptor.capture()); - assertThat(authCaptor.getAllValues()) + @SuppressWarnings("unchecked") + ArgumentCaptor> listCaptor = ArgumentCaptor.forClass(List.class); + verify(userDetails, times(1)).addAuthorizations(listCaptor.capture()); + + List captured = listCaptor.getValue(); + assertThat(captured) .extracting(a -> a.getGroup().getName()) .containsExactlyInAnyOrder("Gruppo-Microsoft-Importato", "altro-gruppo"); - assertThat(authCaptor.getValue().getRole()).isNull(); + captured.forEach(ac -> assertThat(ac.getRole()).isNull()); } @Test @@ -278,8 +281,6 @@ void testDynamicConfigurationGroupOnLogin() throws Exception { manager.init(); - final ArgumentCaptor authCaptor = ArgumentCaptor.forClass(Authorization.class); - manager.processNewUser(userDetails, JWT, false); verify(authorizationManager, times(1)).externalAuthSync(eq("testuser"), anyLong(), anyList(), anyList()); @@ -296,16 +297,19 @@ void testDynamicConfigurationGroupOnLoginNoPersist() throws Exception { // when(userDetails.getUsername()).thenReturn("testuser"); when(userDetails.getUserRepresentation()).thenReturn(userRepresentation); - final ArgumentCaptor authCaptor = ArgumentCaptor.forClass(Authorization.class); - manager.init(); manager.processNewUser(userDetails, JWT, false); verify(authorizationManager, never()).addUserAuthorization(eq("testuser"), any()); - verify(userDetails, times(1)).addAuthorization(authCaptor.capture()); - assertThat(authCaptor.getValue().getGroup().getName()).isEqualTo("group"); - assertThat(authCaptor.getValue().getRole()).isNull(); + @SuppressWarnings("unchecked") + ArgumentCaptor> listCaptor = ArgumentCaptor.forClass(List.class); + verify(userDetails, times(1)).addAuthorizations(listCaptor.capture()); + + List captured = listCaptor.getValue(); + assertThat(captured).hasSize(1); + assertThat(captured.get(0).getGroup().getName()).isEqualTo("group"); + assertThat(captured.get(0).getRole()).isNull(); } @Test @@ -321,8 +325,6 @@ void testDynamicConfigurationGroupRoleOnLogin() throws Exception { manager.init(); - final ArgumentCaptor authCaptor = ArgumentCaptor.forClass(Authorization.class); - manager.processNewUser(userDetails, JWT, false); verify(authorizationManager, times(1)).externalAuthSync(eq("testuser"), anyLong(), anyList(), anyList()); @@ -340,16 +342,19 @@ void testDynamicConfigurationGroupRoleOnLoginNoPersist() throws Exception { manager.init(); - final ArgumentCaptor authCaptor = ArgumentCaptor.forClass(Authorization.class); - manager.processNewUser(userDetails, JWT, false); verify(authorizationManager, never()).addUserAuthorization(eq("testuser"), any()); - verify(userDetails, times(1)).addAuthorization(authCaptor.capture()); - assertThat(authCaptor.getValue().getGroup().getName()).isEqualTo("agroup"); + @SuppressWarnings("unchecked") + ArgumentCaptor> listCaptor = ArgumentCaptor.forClass(List.class); + verify(userDetails, times(1)).addAuthorizations(listCaptor.capture()); + + List captured = listCaptor.getValue(); + assertThat(captured).hasSize(1); + assertThat(captured.get(0).getGroup().getName()).isEqualTo("agroup"); // role doesn't exist, so it's not associated - assertNull(authCaptor.getValue().getRole()); + assertNull(captured.get(0).getRole()); } @Test @@ -365,21 +370,33 @@ void testDynamicConfigurationGroupRoleOnLoginAlreadyPresent() throws Exception { when(configuration.getDefaultAuthorizations()).thenReturn(null); when(configManager.getConfigItem(anyString())).thenReturn(XML_GROUP_ROLE_CONF); + when(userDetails.getUsername()).thenReturn("testuser"); UserRepresentation userRepresentation = new UserRepresentation(); userRepresentation.setAttributes(Map.of("AD_GROUPROLE", List.of("arole_r_agroup"))); -// when(userDetails.getUsername()).thenReturn("testuser"); when(userDetails.getUserRepresentation()).thenReturn(userRepresentation); when(userDetails.getAuthorizations()).thenReturn(new ArrayList<>(List.of(auth))); manager.init(); - final ArgumentCaptor authCaptor = ArgumentCaptor.forClass(Authorization.class); - manager.processNewUser(userDetails, JWT, false); - verify(authorizationManager, never()).addUserAuthorization(eq("testuser"), authCaptor.capture()); + // externalAuthSync is called (persist=FULL) but with empty toAdd since auth is already present + @SuppressWarnings("unchecked") + ArgumentCaptor> toAddCaptor = ArgumentCaptor.forClass(List.class); + @SuppressWarnings("unchecked") + ArgumentCaptor> toDeleteCaptor = ArgumentCaptor.forClass(List.class); + verify(authorizationManager, times(1)).externalAuthSync( + eq("testuser"), anyLong(), toAddCaptor.capture(), toDeleteCaptor.capture()); + assertThat(toAddCaptor.getValue()).isEmpty(); + assertThat(toDeleteCaptor.getValue()).isEmpty(); + + // addAuthorizations called with empty list (nothing new to add) + @SuppressWarnings("unchecked") + ArgumentCaptor> listCaptor = ArgumentCaptor.forClass(List.class); + verify(userDetails, times(1)).addAuthorizations(listCaptor.capture()); + assertThat(listCaptor.getValue()).isEmpty(); } @Test @@ -494,8 +511,6 @@ void testDynamicConfigurationRoleGroupOnLoginFromJwt() throws Exception { manager.init(); - final ArgumentCaptor authCaptor = ArgumentCaptor.forClass(Authorization.class); - manager.processNewUser(userDetails, JWT_ROLEGROUP, false); verify(authorizationManager, times(1)).externalAuthSync(eq("testuser"), anyLong(), anyList(), anyList()); @@ -511,8 +526,6 @@ void testDynamicConfigurationRoleGroupOnLoginFromJwtEdgeCases() throws Exception manager.init(); - final ArgumentCaptor authCaptor = ArgumentCaptor.forClass(Authorization.class); - manager.processNewUser(userDetails, JWT_ROLEGROUP_EDGE, false); verify(authorizationManager, times(1)).externalAuthSync(eq("testuser"), anyLong(), anyList(), anyList()); @@ -527,14 +540,15 @@ void testDynamicConfigurationRoleGroupOnLoginFromJwtNoPersist() throws Exception manager.init(); - final ArgumentCaptor authCaptor = ArgumentCaptor.forClass(Authorization.class); - manager.processNewUser(userDetails, JWT_ROLEGROUP, false); verify(authorizationManager, never()).addUserAuthorization(eq("testuser"), any()); - verify(userDetails, times(2)).addAuthorization(authCaptor.capture()); - List capturedAuths = authCaptor.getAllValues(); + @SuppressWarnings("unchecked") + ArgumentCaptor> listCaptor = ArgumentCaptor.forClass(List.class); + verify(userDetails, times(1)).addAuthorizations(listCaptor.capture()); + + List capturedAuths = listCaptor.getValue(); assertThat(capturedAuths).hasSize(2); assertThat(capturedAuths) @@ -629,11 +643,12 @@ void testRoleFromProfileAndJwtWithPersistAuth() throws Exception { verify(authorizationManager, never()).addUserAuthorization(anyString(), any()); // Verifichiamo che le autorizzazioni siano state aggiunte all'oggetto userDetails - ArgumentCaptor authCaptor = ArgumentCaptor.forClass(Authorization.class); + @SuppressWarnings("unchecked") + ArgumentCaptor> listCaptor = ArgumentCaptor.forClass(List.class); // "generico" e "role_from_profile" (più altri eventuali dal JWT standard se non filtrati) - verify(userDetails, org.mockito.Mockito.atLeastOnce()).addAuthorization(authCaptor.capture()); + verify(userDetails, org.mockito.Mockito.atLeastOnce()).addAuthorizations(listCaptor.capture()); - List captured = authCaptor.getAllValues(); + List captured = listCaptor.getValue(); assertThat(captured).anySatisfy(a -> assertThat(a.getRole().getName()).isEqualTo("role_from_profile")); assertThat(captured).anySatisfy(a -> assertThat(a.getRole().getName()).isEqualTo("generico")); } @@ -672,9 +687,12 @@ void testAuthAssignmentWhenRoleGroupExistWithPersistAuth() throws Exception { manager.processNewUser(userDetails, null, false); // Verifichiamo che l'autorizzazione sia stata aggiunta all'utente - ArgumentCaptor authCaptor = ArgumentCaptor.forClass(Authorization.class); - verify(userDetails, times(1)).addAuthorization(authCaptor.capture()); - assertThat(authCaptor.getValue().getRole().getName()).isEqualTo("existing_role"); + @SuppressWarnings("unchecked") + ArgumentCaptor> listCaptor = ArgumentCaptor.forClass(List.class); + verify(userDetails, times(1)).addAuthorizations(listCaptor.capture()); + List captured = listCaptor.getValue(); + assertThat(captured).hasSize(1); + assertThat(captured.get(0).getRole().getName()).isEqualTo("existing_role"); // Verifichiamo che non sia stata chiamata la persistenza verify(authorizationManager, never()).addUserAuthorization(anyString(), any()); @@ -722,9 +740,12 @@ void testAuthAssignmentWhenRoleExistsAndAddRoleFailsWithPersistAuth() throws Exc manager.processNewUser(userDetails, null, false); // Verifichiamo che l'autorizzazione sia stata comunque aggiunta all'utente - ArgumentCaptor authCaptor = ArgumentCaptor.forClass(Authorization.class); - verify(userDetails, times(1)).addAuthorization(authCaptor.capture()); - assertThat(authCaptor.getValue().getRole().getName()).isEqualTo("conflict_role"); + @SuppressWarnings("unchecked") + ArgumentCaptor> listCaptor = ArgumentCaptor.forClass(List.class); + verify(userDetails, times(1)).addAuthorizations(listCaptor.capture()); + List captured = listCaptor.getValue(); + assertThat(captured).hasSize(1); + assertThat(captured.get(0).getRole().getName()).isEqualTo("conflict_role"); } @@ -758,6 +779,270 @@ void testCleanupManagedAuthorizationsWithRolesAndGroups() throws Exception { verify(authorizationManager, times(1)).externalAuthSync(eq("john"), anyLong(), anyList(), anyList()); } + @Test + void testSyncAuthorizationsRemovesStaleAuthorization() throws Exception { + // User has [A(groupA,roleA), B(groupB,roleB)], dynamic produces only [A] + // B should be in toDelete, removed from user + String xml = "" + + "FULL" + + "true" + + "" + + " trueAD_ROLEROLE" + + "" + + "roleAroleB" + + "groupAgroupB" + + ""; + + when(configuration.getDefaultAuthorizations()).thenReturn(null); + when(configManager.getConfigItem(anyString())).thenReturn(xml); + when(userDetails.getUsername()).thenReturn("testuser"); + + UserRepresentation userRepresentation = new UserRepresentation(); + userRepresentation.setAttributes(Map.of("AD_ROLE", List.of("roleA"))); + when(userDetails.getUserRepresentation()).thenReturn(userRepresentation); + + Role existingRoleA = new Role(); + existingRoleA.setName("roleA"); + when(roleManager.getRole("roleA")).thenReturn(existingRoleA); + + Authorization authA = roleOnlyAuthorization("roleA"); + Authorization authB = roleOnlyAuthorization("roleB"); + List userAuths = new ArrayList<>(List.of(authA, authB)); + when(userDetails.getAuthorizations()).thenReturn(userAuths); + + manager.init(); + manager.processNewUser(userDetails, null, false); + + // Capture externalAuthSync args + @SuppressWarnings("unchecked") + ArgumentCaptor> toAddCaptor = ArgumentCaptor.forClass(List.class); + @SuppressWarnings("unchecked") + ArgumentCaptor> toDeleteCaptor = ArgumentCaptor.forClass(List.class); + verify(authorizationManager, times(1)).externalAuthSync( + eq("testuser"), anyLong(), toAddCaptor.capture(), toDeleteCaptor.capture()); + + // A is already assigned, so toAdd should be empty + assertThat(toAddCaptor.getValue()).isEmpty(); + // B is stale (managed but not in dynamic), so it should be in toDelete + assertThat(toDeleteCaptor.getValue()).hasSize(1); + assertThat(toDeleteCaptor.getValue().get(0).getRole().getName()).isEqualTo("roleB"); + + // After removeAll, the user's mutable list should no longer contain B + assertThat(userAuths).hasSize(1); + assertThat(userAuths.get(0).getRole().getName()).isEqualTo("roleA"); + } + + @Test + void testSyncAuthorizationsMixAddAndDelete() throws Exception { + // User has [A(roleA)], dynamic produces [B(roleB)]. A deleted, B added. + String xml = "" + + "FULL" + + "true" + + "" + + " trueAD_ROLEROLE" + + "" + + "roleAroleB" + + "" + + ""; + + when(configuration.getDefaultAuthorizations()).thenReturn(null); + when(configManager.getConfigItem(anyString())).thenReturn(xml); + when(userDetails.getUsername()).thenReturn("testuser"); + + UserRepresentation userRepresentation = new UserRepresentation(); + userRepresentation.setAttributes(Map.of("AD_ROLE", List.of("roleB"))); + when(userDetails.getUserRepresentation()).thenReturn(userRepresentation); + + Role existingRoleB = new Role(); + existingRoleB.setName("roleB"); + when(roleManager.getRole("roleB")).thenReturn(existingRoleB); + + Authorization authA = roleOnlyAuthorization("roleA"); + List userAuths = new ArrayList<>(List.of(authA)); + when(userDetails.getAuthorizations()).thenReturn(userAuths); + + manager.init(); + manager.processNewUser(userDetails, null, false); + + @SuppressWarnings("unchecked") + ArgumentCaptor> toAddCaptor = ArgumentCaptor.forClass(List.class); + @SuppressWarnings("unchecked") + ArgumentCaptor> toDeleteCaptor = ArgumentCaptor.forClass(List.class); + verify(authorizationManager, times(1)).externalAuthSync( + eq("testuser"), anyLong(), toAddCaptor.capture(), toDeleteCaptor.capture()); + + assertThat(toAddCaptor.getValue()).hasSize(1); + assertThat(toAddCaptor.getValue().get(0).getRole().getName()).isEqualTo("roleB"); + + assertThat(toDeleteCaptor.getValue()).hasSize(1); + assertThat(toDeleteCaptor.getValue().get(0).getRole().getName()).isEqualTo("roleA"); + } + + @Test + void testProcessNewUserDisabledSkipsAuthProcessing() throws Exception { + when(configuration.getDefaultAuthorizations()).thenReturn(null); + when(configManager.getConfigItem(anyString())).thenReturn(XML_DISABLED); + lenient().when(userDetails.getAuthorizations()).thenReturn(new ArrayList<>()); + + manager.init(); + manager.processNewUser(userDetails, JWT, false); + + verify(authorizationManager, never()).externalAuthSync(anyString(), anyLong(), anyList(), anyList()); + verify(userDetails, never()).addAuthorizations(anyList()); + } + + @Test + void testProcessNewUserAlreadySyncedSkipsProcessing() throws Exception { + when(configuration.getDefaultAuthorizations()).thenReturn(null); + when(configManager.getConfigItem(anyString())).thenReturn(XML_ROLE_CLAIM); + lenient().when(userDetails.getAuthorizations()).thenReturn(new ArrayList<>()); + when(userDetails.getUsername()).thenReturn("testuser"); + + // Override the lenient stub: checkExternalAuthSync returns true + when(authorizationManager.checkExternalAuthSync(eq("testuser"), anyLong())).thenReturn(true); + + manager.init(); + manager.processNewUser(userDetails, JWT, false); + + verify(authorizationManager, never()).externalAuthSync(anyString(), anyLong(), anyList(), anyList()); + verify(userDetails, never()).addAuthorizations(anyList()); + } + + @Test + void testRefreshConfigurationSuccess() throws Exception { + // First init with no mappings + when(configuration.getDefaultAuthorizations()).thenReturn(null); + when(configManager.getConfigItem(anyString())).thenReturn(XML_NO_MAPPING); + when(userDetails.getAuthorizations()).thenReturn(new ArrayList<>()); + when(userDetails.getUsername()).thenReturn("testuser"); + + manager.init(); + manager.processNewUser(userDetails, JWT, false); + + // No real mappings → externalAuthSync called with empty lists + @SuppressWarnings("unchecked") + ArgumentCaptor> toAddCaptor1 = ArgumentCaptor.forClass(List.class); + verify(authorizationManager, times(1)).externalAuthSync( + eq("testuser"), anyLong(), toAddCaptor1.capture(), anyList()); + assertThat(toAddCaptor1.getValue()).isEmpty(); + + // Now change config to have a real mapping and refresh + when(configManager.getConfigItem(anyString())).thenReturn(XML_ROLE_CLAIM); + + manager.refreshConfiguration(); + + // Reset the checkExternalAuthSync to allow re-processing + when(authorizationManager.checkExternalAuthSync(anyString(), anyLong())).thenReturn(false); + + manager.processNewUser(userDetails, JWT, false); + + // After refresh, the ROLECLAIM mapping should be active and produce "generico" + verify(authorizationManager, times(2)).externalAuthSync( + eq("testuser"), anyLong(), anyList(), anyList()); + } + + @Test + void testRefreshConfigurationErrorDoesNotThrow() throws Exception { + // First init with valid config + when(configuration.getDefaultAuthorizations()).thenReturn(null); + when(configManager.getConfigItem(anyString())).thenReturn(XML_NO_MAPPING); + manager.init(); + + // Now refresh with malformed XML - should NOT throw + when(configManager.getConfigItem(anyString())).thenReturn(XML_MALFORMED_MAPPING); + manager.refreshConfiguration(); // should not throw + + // After error, enabled=false, so processNewUser skips dynamic processing + lenient().when(userDetails.getAuthorizations()).thenReturn(new ArrayList<>()); + manager.processNewUser(userDetails, JWT, false); + + verify(authorizationManager, never()).externalAuthSync(anyString(), anyLong(), anyList(), anyList()); + verify(userDetails, never()).addAuthorizations(anyList()); + } + + @Test + void testPersistNoneDoesNotCallExternalAuthSync() throws Exception { + when(configuration.getDefaultAuthorizations()).thenReturn(null); + when(configManager.getConfigItem(anyString())).thenReturn(XML_ROLE_CLAIM_NONE); + when(userDetails.getAuthorizations()).thenReturn(new ArrayList<>()); + when(userDetails.getUsername()).thenReturn("testuser"); + + manager.init(); + manager.processNewUser(userDetails, JWT, false); + + // NONE → externalAuthSync never called + verify(authorizationManager, never()).externalAuthSync(anyString(), anyLong(), anyList(), anyList()); + + // But addAuthorizations IS called with the dynamic auths + @SuppressWarnings("unchecked") + ArgumentCaptor> listCaptor = ArgumentCaptor.forClass(List.class); + verify(userDetails, times(1)).addAuthorizations(listCaptor.capture()); + + List captured = listCaptor.getValue(); + assertThat(captured).isNotEmpty(); + assertThat(captured).extracting(a -> a.getRole().getName()).contains("generico"); + } + + @Test + void testPersistNoneCreatesTransientObjectsNotPersisted() throws Exception { + when(configuration.getDefaultAuthorizations()).thenReturn(null); + when(configManager.getConfigItem(anyString())).thenReturn(XML_GROUP_ROLE_CONF_NO_PERSIST); + when(userDetails.getAuthorizations()).thenReturn(new ArrayList<>()); + + UserRepresentation userRepresentation = new UserRepresentation(); + userRepresentation.setAttributes(Map.of("AD_GROUPROLE", List.of("arole_r_agroup"))); + when(userDetails.getUserRepresentation()).thenReturn(userRepresentation); + + manager.init(); + manager.processNewUser(userDetails, JWT, false); + + // NONE persist → no DB operations for role/group creation + verify(roleManager, never()).addRole(any()); + verify(groupManager, never()).addGroup(any()); + + // But authorizations are still added to the user object + @SuppressWarnings("unchecked") + ArgumentCaptor> listCaptor = ArgumentCaptor.forClass(List.class); + verify(userDetails, times(1)).addAuthorizations(listCaptor.capture()); + + List captured = listCaptor.getValue(); + assertThat(captured).hasSize(1); + // Transient group has description starting with "sys:" + assertThat(captured.get(0).getGroup().getDescription()).startsWith("sys:"); + } + + @Test + void testExternalAuthSyncArgsVerifiedExactly() throws Exception { + when(configuration.getDefaultAuthorizations()).thenReturn(null); + when(configManager.getConfigItem(anyString())).thenReturn(XML_ROLE_CLAIM); + when(userDetails.getAuthorizations()).thenReturn(new ArrayList<>()); + when(userDetails.getUsername()).thenReturn("testuser"); + + manager.init(); + manager.processNewUser(userDetails, JWT, false); + + ArgumentCaptor usernameCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor iatCaptor = ArgumentCaptor.forClass(Long.class); + @SuppressWarnings("unchecked") + ArgumentCaptor> toAddCaptor = ArgumentCaptor.forClass(List.class); + @SuppressWarnings("unchecked") + ArgumentCaptor> toDeleteCaptor = ArgumentCaptor.forClass(List.class); + + verify(authorizationManager, times(1)).externalAuthSync( + usernameCaptor.capture(), iatCaptor.capture(), toAddCaptor.capture(), toDeleteCaptor.capture()); + + assertThat(usernameCaptor.getValue()).isEqualTo("testuser"); + assertThat(iatCaptor.getValue()).isEqualTo(1768319143L); + + // toAdd should have exactly "generico" (the only managed role in JWT) + assertThat(toAddCaptor.getValue()).hasSize(1); + assertThat(toAddCaptor.getValue().get(0).getRole().getName()).isEqualTo("generico"); + assertThat(toAddCaptor.getValue().get(0).getGroup()).isNull(); + + // toDelete should be empty (user had no existing managed auths) + assertThat(toDeleteCaptor.getValue()).isEmpty(); + } + private Authorization authorization(final String groupName, final String roleName) { final Group group = new Group(); group.setName(groupName); @@ -766,6 +1051,12 @@ private Authorization authorization(final String groupName, final String roleNam return new Authorization(group, role); } + private Authorization roleOnlyAuthorization(final String roleName) { + final Role role = new Role(); + role.setName(roleName); + return new Authorization(null, role); + } + private static final String XML_ROLE_CONF = "" + "" @@ -1217,6 +1508,42 @@ private Authorization authorization(final String groupName, final String roleNam + " " + ""; + private static final String XML_DISABLED = "" + + "FULL" + + "false" + + "" + + " truerealm_access.rolesROLECLAIM" + + "" + + "generico" + + "agroup" + + ""; + + private static final String XML_ROLE_CLAIM_NONE = "" + + " NONE" + + " true" + + "" + + " " + + " true" + + " realm_access.roles" + + " ROLECLAIM" + + " " + + "" + + "" + + " default-roles-entando-development" + + " offline_access" + + " uma_authorization" + + " default-roles-entando" + + " " + + " " + + " generico" + + " imported_role2" + + " " + + " " + + " imported_group" + + " imported_group2" + + " " + + ""; + private static final String JWT_NO_ROLE = "{" + " \"header\" : {" + " \"alg\" : \"RS256\"," From 12366577b0790ca7212ac4e6646670a143424ab0 Mon Sep 17 00:00:00 2001 From: ffalqui Date: Thu, 26 Feb 2026 18:12:17 +0100 Subject: [PATCH 37/44] ESB-950 enhance test coverage for auth sync, config, and user processing --- .../KeycloakAuthorizationManagerTest.java | 431 +++++++++++++++--- 1 file changed, 379 insertions(+), 52 deletions(-) diff --git a/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java b/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java index 6cb989687..336067fb1 100644 --- a/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java +++ b/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java @@ -147,7 +147,12 @@ void testVerification() throws Exception { verify(roleManager, times(0)).getRole(anyString()); verify(groupManager, times(0)).getGroup(anyString()); - verify(userDetails, times(0)).addAuthorization(any()); + + @SuppressWarnings("unchecked") + ArgumentCaptor> listCaptor = ArgumentCaptor.forClass(List.class); + verify(userDetails, times(1)).addAuthorizations(listCaptor.capture()); + assertThat(listCaptor.getValue()).isEmpty(); + verify(authorizationManager, never()).addUserAuthorization(anyString(), any()); } @@ -166,8 +171,6 @@ void testDynamicConfigurationRoleOnLogin() throws Exception { manager.init(); - final ArgumentCaptor authCaptor = ArgumentCaptor.forClass(Authorization.class); - manager.processNewUser(userDetails, JWT, false); verify(authorizationManager, times(1)).externalAuthSync(eq("testuser"), anyLong(), anyList(), anyList()); @@ -183,8 +186,6 @@ void testDynamicConfigurationRoleOnLoginFromJwt() throws Exception { manager.init(); - final ArgumentCaptor authCaptor = ArgumentCaptor.forClass(Authorization.class); - manager.processNewUser(userDetails, JWT, false); verify(authorizationManager, times(1)).externalAuthSync(eq("testuser"), anyLong(), anyList(), anyList()); @@ -200,17 +201,19 @@ void testDynamicConfigurationRoleOnLoginFromJwtNoPersist() throws Exception { manager.init(); - final ArgumentCaptor authCaptor = ArgumentCaptor.forClass(Authorization.class); - manager.processNewUser(userDetails, JWT, false); verify(authorizationManager, never()).addUserAuthorization(eq("testuser"), any()); - verify(userDetails, times(1)).addAuthorization(authCaptor.capture()); - assertThat(authCaptor.getAllValues()) + @SuppressWarnings("unchecked") + ArgumentCaptor> listCaptor = ArgumentCaptor.forClass(List.class); + verify(userDetails, times(1)).addAuthorizations(listCaptor.capture()); + + List captured = listCaptor.getValue(); + assertThat(captured) .extracting(a -> a.getRole().getName()) .containsOnly("generico"); - assertThat(authCaptor.getValue().getGroup()).isNull(); + assertThat(captured.get(0).getGroup()).isNull(); } @Test @@ -222,8 +225,6 @@ void testDynamicConfigurationGroupOnLoginFromJwt() throws Exception { manager.init(); - final ArgumentCaptor authCaptor = ArgumentCaptor.forClass(Authorization.class); - manager.processNewUser(userDetails, JWT, false); verify(authorizationManager, times(1)).externalAuthSync(eq("testuser"), anyLong(), anyList(), anyList()); @@ -238,17 +239,19 @@ void testDynamicConfigurationGroupOnLoginFromJwtNoPersist() throws Exception { manager.init(); - final ArgumentCaptor authCaptor = ArgumentCaptor.forClass(Authorization.class); - manager.processNewUser(userDetails, JWT, false); verify(authorizationManager, never()).addUserAuthorization(eq("testuser"), any()); - verify(userDetails, times(2)).addAuthorization(authCaptor.capture()); - assertThat(authCaptor.getAllValues()) + @SuppressWarnings("unchecked") + ArgumentCaptor> listCaptor = ArgumentCaptor.forClass(List.class); + verify(userDetails, times(1)).addAuthorizations(listCaptor.capture()); + + List captured = listCaptor.getValue(); + assertThat(captured) .extracting(a -> a.getGroup().getName()) .containsExactlyInAnyOrder("Gruppo-Microsoft-Importato", "altro-gruppo"); - assertThat(authCaptor.getValue().getRole()).isNull(); + captured.forEach(ac -> assertThat(ac.getRole()).isNull()); } @Test @@ -278,8 +281,6 @@ void testDynamicConfigurationGroupOnLogin() throws Exception { manager.init(); - final ArgumentCaptor authCaptor = ArgumentCaptor.forClass(Authorization.class); - manager.processNewUser(userDetails, JWT, false); verify(authorizationManager, times(1)).externalAuthSync(eq("testuser"), anyLong(), anyList(), anyList()); @@ -296,16 +297,19 @@ void testDynamicConfigurationGroupOnLoginNoPersist() throws Exception { // when(userDetails.getUsername()).thenReturn("testuser"); when(userDetails.getUserRepresentation()).thenReturn(userRepresentation); - final ArgumentCaptor authCaptor = ArgumentCaptor.forClass(Authorization.class); - manager.init(); manager.processNewUser(userDetails, JWT, false); verify(authorizationManager, never()).addUserAuthorization(eq("testuser"), any()); - verify(userDetails, times(1)).addAuthorization(authCaptor.capture()); - assertThat(authCaptor.getValue().getGroup().getName()).isEqualTo("group"); - assertThat(authCaptor.getValue().getRole()).isNull(); + @SuppressWarnings("unchecked") + ArgumentCaptor> listCaptor = ArgumentCaptor.forClass(List.class); + verify(userDetails, times(1)).addAuthorizations(listCaptor.capture()); + + List captured = listCaptor.getValue(); + assertThat(captured).hasSize(1); + assertThat(captured.get(0).getGroup().getName()).isEqualTo("group"); + assertThat(captured.get(0).getRole()).isNull(); } @Test @@ -321,8 +325,6 @@ void testDynamicConfigurationGroupRoleOnLogin() throws Exception { manager.init(); - final ArgumentCaptor authCaptor = ArgumentCaptor.forClass(Authorization.class); - manager.processNewUser(userDetails, JWT, false); verify(authorizationManager, times(1)).externalAuthSync(eq("testuser"), anyLong(), anyList(), anyList()); @@ -340,16 +342,19 @@ void testDynamicConfigurationGroupRoleOnLoginNoPersist() throws Exception { manager.init(); - final ArgumentCaptor authCaptor = ArgumentCaptor.forClass(Authorization.class); - manager.processNewUser(userDetails, JWT, false); verify(authorizationManager, never()).addUserAuthorization(eq("testuser"), any()); - verify(userDetails, times(1)).addAuthorization(authCaptor.capture()); - assertThat(authCaptor.getValue().getGroup().getName()).isEqualTo("agroup"); + @SuppressWarnings("unchecked") + ArgumentCaptor> listCaptor = ArgumentCaptor.forClass(List.class); + verify(userDetails, times(1)).addAuthorizations(listCaptor.capture()); + + List captured = listCaptor.getValue(); + assertThat(captured).hasSize(1); + assertThat(captured.get(0).getGroup().getName()).isEqualTo("agroup"); // role doesn't exist, so it's not associated - assertNull(authCaptor.getValue().getRole()); + assertNull(captured.get(0).getRole()); } @Test @@ -365,21 +370,33 @@ void testDynamicConfigurationGroupRoleOnLoginAlreadyPresent() throws Exception { when(configuration.getDefaultAuthorizations()).thenReturn(null); when(configManager.getConfigItem(anyString())).thenReturn(XML_GROUP_ROLE_CONF); + when(userDetails.getUsername()).thenReturn("testuser"); UserRepresentation userRepresentation = new UserRepresentation(); userRepresentation.setAttributes(Map.of("AD_GROUPROLE", List.of("arole_r_agroup"))); -// when(userDetails.getUsername()).thenReturn("testuser"); when(userDetails.getUserRepresentation()).thenReturn(userRepresentation); when(userDetails.getAuthorizations()).thenReturn(new ArrayList<>(List.of(auth))); manager.init(); - final ArgumentCaptor authCaptor = ArgumentCaptor.forClass(Authorization.class); - manager.processNewUser(userDetails, JWT, false); - verify(authorizationManager, never()).addUserAuthorization(eq("testuser"), authCaptor.capture()); + // externalAuthSync is called (persist=FULL) but with empty toAdd since auth is already present + @SuppressWarnings("unchecked") + ArgumentCaptor> toAddCaptor = ArgumentCaptor.forClass(List.class); + @SuppressWarnings("unchecked") + ArgumentCaptor> toDeleteCaptor = ArgumentCaptor.forClass(List.class); + verify(authorizationManager, times(1)).externalAuthSync( + eq("testuser"), anyLong(), toAddCaptor.capture(), toDeleteCaptor.capture()); + assertThat(toAddCaptor.getValue()).isEmpty(); + assertThat(toDeleteCaptor.getValue()).isEmpty(); + + // addAuthorizations called with empty list (nothing new to add) + @SuppressWarnings("unchecked") + ArgumentCaptor> listCaptor = ArgumentCaptor.forClass(List.class); + verify(userDetails, times(1)).addAuthorizations(listCaptor.capture()); + assertThat(listCaptor.getValue()).isEmpty(); } @Test @@ -494,8 +511,6 @@ void testDynamicConfigurationRoleGroupOnLoginFromJwt() throws Exception { manager.init(); - final ArgumentCaptor authCaptor = ArgumentCaptor.forClass(Authorization.class); - manager.processNewUser(userDetails, JWT_ROLEGROUP, false); verify(authorizationManager, times(1)).externalAuthSync(eq("testuser"), anyLong(), anyList(), anyList()); @@ -511,8 +526,6 @@ void testDynamicConfigurationRoleGroupOnLoginFromJwtEdgeCases() throws Exception manager.init(); - final ArgumentCaptor authCaptor = ArgumentCaptor.forClass(Authorization.class); - manager.processNewUser(userDetails, JWT_ROLEGROUP_EDGE, false); verify(authorizationManager, times(1)).externalAuthSync(eq("testuser"), anyLong(), anyList(), anyList()); @@ -527,14 +540,15 @@ void testDynamicConfigurationRoleGroupOnLoginFromJwtNoPersist() throws Exception manager.init(); - final ArgumentCaptor authCaptor = ArgumentCaptor.forClass(Authorization.class); - manager.processNewUser(userDetails, JWT_ROLEGROUP, false); verify(authorizationManager, never()).addUserAuthorization(eq("testuser"), any()); - verify(userDetails, times(2)).addAuthorization(authCaptor.capture()); - List capturedAuths = authCaptor.getAllValues(); + @SuppressWarnings("unchecked") + ArgumentCaptor> listCaptor = ArgumentCaptor.forClass(List.class); + verify(userDetails, times(1)).addAuthorizations(listCaptor.capture()); + + List capturedAuths = listCaptor.getValue(); assertThat(capturedAuths).hasSize(2); assertThat(capturedAuths) @@ -629,11 +643,12 @@ void testRoleFromProfileAndJwtWithPersistAuth() throws Exception { verify(authorizationManager, never()).addUserAuthorization(anyString(), any()); // Verifichiamo che le autorizzazioni siano state aggiunte all'oggetto userDetails - ArgumentCaptor authCaptor = ArgumentCaptor.forClass(Authorization.class); + @SuppressWarnings("unchecked") + ArgumentCaptor> listCaptor = ArgumentCaptor.forClass(List.class); // "generico" e "role_from_profile" (più altri eventuali dal JWT standard se non filtrati) - verify(userDetails, org.mockito.Mockito.atLeastOnce()).addAuthorization(authCaptor.capture()); + verify(userDetails, org.mockito.Mockito.atLeastOnce()).addAuthorizations(listCaptor.capture()); - List captured = authCaptor.getAllValues(); + List captured = listCaptor.getValue(); assertThat(captured).anySatisfy(a -> assertThat(a.getRole().getName()).isEqualTo("role_from_profile")); assertThat(captured).anySatisfy(a -> assertThat(a.getRole().getName()).isEqualTo("generico")); } @@ -672,9 +687,12 @@ void testAuthAssignmentWhenRoleGroupExistWithPersistAuth() throws Exception { manager.processNewUser(userDetails, null, false); // Verifichiamo che l'autorizzazione sia stata aggiunta all'utente - ArgumentCaptor authCaptor = ArgumentCaptor.forClass(Authorization.class); - verify(userDetails, times(1)).addAuthorization(authCaptor.capture()); - assertThat(authCaptor.getValue().getRole().getName()).isEqualTo("existing_role"); + @SuppressWarnings("unchecked") + ArgumentCaptor> listCaptor = ArgumentCaptor.forClass(List.class); + verify(userDetails, times(1)).addAuthorizations(listCaptor.capture()); + List captured = listCaptor.getValue(); + assertThat(captured).hasSize(1); + assertThat(captured.get(0).getRole().getName()).isEqualTo("existing_role"); // Verifichiamo che non sia stata chiamata la persistenza verify(authorizationManager, never()).addUserAuthorization(anyString(), any()); @@ -722,9 +740,12 @@ void testAuthAssignmentWhenRoleExistsAndAddRoleFailsWithPersistAuth() throws Exc manager.processNewUser(userDetails, null, false); // Verifichiamo che l'autorizzazione sia stata comunque aggiunta all'utente - ArgumentCaptor authCaptor = ArgumentCaptor.forClass(Authorization.class); - verify(userDetails, times(1)).addAuthorization(authCaptor.capture()); - assertThat(authCaptor.getValue().getRole().getName()).isEqualTo("conflict_role"); + @SuppressWarnings("unchecked") + ArgumentCaptor> listCaptor = ArgumentCaptor.forClass(List.class); + verify(userDetails, times(1)).addAuthorizations(listCaptor.capture()); + List captured = listCaptor.getValue(); + assertThat(captured).hasSize(1); + assertThat(captured.get(0).getRole().getName()).isEqualTo("conflict_role"); } @@ -758,6 +779,270 @@ void testCleanupManagedAuthorizationsWithRolesAndGroups() throws Exception { verify(authorizationManager, times(1)).externalAuthSync(eq("john"), anyLong(), anyList(), anyList()); } + @Test + void testSyncAuthorizationsRemovesStaleAuthorization() throws Exception { + // User has [A(groupA,roleA), B(groupB,roleB)], dynamic produces only [A] + // B should be in toDelete, removed from user + String xml = "" + + "FULL" + + "true" + + "" + + " trueAD_ROLEROLE" + + "" + + "roleAroleB" + + "groupAgroupB" + + ""; + + when(configuration.getDefaultAuthorizations()).thenReturn(null); + when(configManager.getConfigItem(anyString())).thenReturn(xml); + when(userDetails.getUsername()).thenReturn("testuser"); + + UserRepresentation userRepresentation = new UserRepresentation(); + userRepresentation.setAttributes(Map.of("AD_ROLE", List.of("roleA"))); + when(userDetails.getUserRepresentation()).thenReturn(userRepresentation); + + Role existingRoleA = new Role(); + existingRoleA.setName("roleA"); + when(roleManager.getRole("roleA")).thenReturn(existingRoleA); + + Authorization authA = roleOnlyAuthorization("roleA"); + Authorization authB = roleOnlyAuthorization("roleB"); + List userAuths = new ArrayList<>(List.of(authA, authB)); + when(userDetails.getAuthorizations()).thenReturn(userAuths); + + manager.init(); + manager.processNewUser(userDetails, null, false); + + // Capture externalAuthSync args + @SuppressWarnings("unchecked") + ArgumentCaptor> toAddCaptor = ArgumentCaptor.forClass(List.class); + @SuppressWarnings("unchecked") + ArgumentCaptor> toDeleteCaptor = ArgumentCaptor.forClass(List.class); + verify(authorizationManager, times(1)).externalAuthSync( + eq("testuser"), anyLong(), toAddCaptor.capture(), toDeleteCaptor.capture()); + + // A is already assigned, so toAdd should be empty + assertThat(toAddCaptor.getValue()).isEmpty(); + // B is stale (managed but not in dynamic), so it should be in toDelete + assertThat(toDeleteCaptor.getValue()).hasSize(1); + assertThat(toDeleteCaptor.getValue().get(0).getRole().getName()).isEqualTo("roleB"); + + // After removeAll, the user's mutable list should no longer contain B + assertThat(userAuths).hasSize(1); + assertThat(userAuths.get(0).getRole().getName()).isEqualTo("roleA"); + } + + @Test + void testSyncAuthorizationsMixAddAndDelete() throws Exception { + // User has [A(roleA)], dynamic produces [B(roleB)]. A deleted, B added. + String xml = "" + + "FULL" + + "true" + + "" + + " trueAD_ROLEROLE" + + "" + + "roleAroleB" + + "" + + ""; + + when(configuration.getDefaultAuthorizations()).thenReturn(null); + when(configManager.getConfigItem(anyString())).thenReturn(xml); + when(userDetails.getUsername()).thenReturn("testuser"); + + UserRepresentation userRepresentation = new UserRepresentation(); + userRepresentation.setAttributes(Map.of("AD_ROLE", List.of("roleB"))); + when(userDetails.getUserRepresentation()).thenReturn(userRepresentation); + + Role existingRoleB = new Role(); + existingRoleB.setName("roleB"); + when(roleManager.getRole("roleB")).thenReturn(existingRoleB); + + Authorization authA = roleOnlyAuthorization("roleA"); + List userAuths = new ArrayList<>(List.of(authA)); + when(userDetails.getAuthorizations()).thenReturn(userAuths); + + manager.init(); + manager.processNewUser(userDetails, null, false); + + @SuppressWarnings("unchecked") + ArgumentCaptor> toAddCaptor = ArgumentCaptor.forClass(List.class); + @SuppressWarnings("unchecked") + ArgumentCaptor> toDeleteCaptor = ArgumentCaptor.forClass(List.class); + verify(authorizationManager, times(1)).externalAuthSync( + eq("testuser"), anyLong(), toAddCaptor.capture(), toDeleteCaptor.capture()); + + assertThat(toAddCaptor.getValue()).hasSize(1); + assertThat(toAddCaptor.getValue().get(0).getRole().getName()).isEqualTo("roleB"); + + assertThat(toDeleteCaptor.getValue()).hasSize(1); + assertThat(toDeleteCaptor.getValue().get(0).getRole().getName()).isEqualTo("roleA"); + } + + @Test + void testProcessNewUserDisabledSkipsAuthProcessing() throws Exception { + when(configuration.getDefaultAuthorizations()).thenReturn(null); + when(configManager.getConfigItem(anyString())).thenReturn(XML_DISABLED); + lenient().when(userDetails.getAuthorizations()).thenReturn(new ArrayList<>()); + + manager.init(); + manager.processNewUser(userDetails, JWT, false); + + verify(authorizationManager, never()).externalAuthSync(anyString(), anyLong(), anyList(), anyList()); + verify(userDetails, never()).addAuthorizations(anyList()); + } + + @Test + void testProcessNewUserAlreadySyncedSkipsProcessing() throws Exception { + when(configuration.getDefaultAuthorizations()).thenReturn(null); + when(configManager.getConfigItem(anyString())).thenReturn(XML_ROLE_CLAIM); + lenient().when(userDetails.getAuthorizations()).thenReturn(new ArrayList<>()); + when(userDetails.getUsername()).thenReturn("testuser"); + + // Override the lenient stub: checkExternalAuthSync returns true + when(authorizationManager.checkExternalAuthSync(eq("testuser"), anyLong())).thenReturn(true); + + manager.init(); + manager.processNewUser(userDetails, JWT, false); + + verify(authorizationManager, never()).externalAuthSync(anyString(), anyLong(), anyList(), anyList()); + verify(userDetails, never()).addAuthorizations(anyList()); + } + + @Test + void testRefreshConfigurationSuccess() throws Exception { + // First init with no mappings + when(configuration.getDefaultAuthorizations()).thenReturn(null); + when(configManager.getConfigItem(anyString())).thenReturn(XML_NO_MAPPING); + when(userDetails.getAuthorizations()).thenReturn(new ArrayList<>()); + when(userDetails.getUsername()).thenReturn("testuser"); + + manager.init(); + manager.processNewUser(userDetails, JWT, false); + + // No real mappings → externalAuthSync called with empty lists + @SuppressWarnings("unchecked") + ArgumentCaptor> toAddCaptor1 = ArgumentCaptor.forClass(List.class); + verify(authorizationManager, times(1)).externalAuthSync( + eq("testuser"), anyLong(), toAddCaptor1.capture(), anyList()); + assertThat(toAddCaptor1.getValue()).isEmpty(); + + // Now change config to have a real mapping and refresh + when(configManager.getConfigItem(anyString())).thenReturn(XML_ROLE_CLAIM); + + manager.refreshConfiguration(); + + // Reset the checkExternalAuthSync to allow re-processing + when(authorizationManager.checkExternalAuthSync(anyString(), anyLong())).thenReturn(false); + + manager.processNewUser(userDetails, JWT, false); + + // After refresh, the ROLECLAIM mapping should be active and produce "generico" + verify(authorizationManager, times(2)).externalAuthSync( + eq("testuser"), anyLong(), anyList(), anyList()); + } + + @Test + void testRefreshConfigurationErrorDoesNotThrow() throws Exception { + // First init with valid config + when(configuration.getDefaultAuthorizations()).thenReturn(null); + when(configManager.getConfigItem(anyString())).thenReturn(XML_NO_MAPPING); + manager.init(); + + // Now refresh with malformed XML - should NOT throw + when(configManager.getConfigItem(anyString())).thenReturn(XML_MALFORMED_MAPPING); + manager.refreshConfiguration(); // should not throw + + // After error, enabled=false, so processNewUser skips dynamic processing + lenient().when(userDetails.getAuthorizations()).thenReturn(new ArrayList<>()); + manager.processNewUser(userDetails, JWT, false); + + verify(authorizationManager, never()).externalAuthSync(anyString(), anyLong(), anyList(), anyList()); + verify(userDetails, never()).addAuthorizations(anyList()); + } + + @Test + void testPersistNoneDoesNotCallExternalAuthSync() throws Exception { + when(configuration.getDefaultAuthorizations()).thenReturn(null); + when(configManager.getConfigItem(anyString())).thenReturn(XML_ROLE_CLAIM_NONE); + when(userDetails.getAuthorizations()).thenReturn(new ArrayList<>()); + when(userDetails.getUsername()).thenReturn("testuser"); + + manager.init(); + manager.processNewUser(userDetails, JWT, false); + + // NONE → externalAuthSync never called + verify(authorizationManager, never()).externalAuthSync(anyString(), anyLong(), anyList(), anyList()); + + // But addAuthorizations IS called with the dynamic auths + @SuppressWarnings("unchecked") + ArgumentCaptor> listCaptor = ArgumentCaptor.forClass(List.class); + verify(userDetails, times(1)).addAuthorizations(listCaptor.capture()); + + List captured = listCaptor.getValue(); + assertThat(captured).isNotEmpty(); + assertThat(captured).extracting(a -> a.getRole().getName()).contains("generico"); + } + + @Test + void testPersistNoneCreatesTransientObjectsNotPersisted() throws Exception { + when(configuration.getDefaultAuthorizations()).thenReturn(null); + when(configManager.getConfigItem(anyString())).thenReturn(XML_GROUP_ROLE_CONF_NO_PERSIST); + when(userDetails.getAuthorizations()).thenReturn(new ArrayList<>()); + + UserRepresentation userRepresentation = new UserRepresentation(); + userRepresentation.setAttributes(Map.of("AD_GROUPROLE", List.of("arole_r_agroup"))); + when(userDetails.getUserRepresentation()).thenReturn(userRepresentation); + + manager.init(); + manager.processNewUser(userDetails, JWT, false); + + // NONE persist → no DB operations for role/group creation + verify(roleManager, never()).addRole(any()); + verify(groupManager, never()).addGroup(any()); + + // But authorizations are still added to the user object + @SuppressWarnings("unchecked") + ArgumentCaptor> listCaptor = ArgumentCaptor.forClass(List.class); + verify(userDetails, times(1)).addAuthorizations(listCaptor.capture()); + + List captured = listCaptor.getValue(); + assertThat(captured).hasSize(1); + // Transient group has description starting with "sys:" + assertThat(captured.get(0).getGroup().getDescription()).startsWith("sys:"); + } + + @Test + void testExternalAuthSyncArgsVerifiedExactly() throws Exception { + when(configuration.getDefaultAuthorizations()).thenReturn(null); + when(configManager.getConfigItem(anyString())).thenReturn(XML_ROLE_CLAIM); + when(userDetails.getAuthorizations()).thenReturn(new ArrayList<>()); + when(userDetails.getUsername()).thenReturn("testuser"); + + manager.init(); + manager.processNewUser(userDetails, JWT, false); + + ArgumentCaptor usernameCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor iatCaptor = ArgumentCaptor.forClass(Long.class); + @SuppressWarnings("unchecked") + ArgumentCaptor> toAddCaptor = ArgumentCaptor.forClass(List.class); + @SuppressWarnings("unchecked") + ArgumentCaptor> toDeleteCaptor = ArgumentCaptor.forClass(List.class); + + verify(authorizationManager, times(1)).externalAuthSync( + usernameCaptor.capture(), iatCaptor.capture(), toAddCaptor.capture(), toDeleteCaptor.capture()); + + assertThat(usernameCaptor.getValue()).isEqualTo("testuser"); + assertThat(iatCaptor.getValue()).isEqualTo(1768319143L); + + // toAdd should have exactly "generico" (the only managed role in JWT) + assertThat(toAddCaptor.getValue()).hasSize(1); + assertThat(toAddCaptor.getValue().get(0).getRole().getName()).isEqualTo("generico"); + assertThat(toAddCaptor.getValue().get(0).getGroup()).isNull(); + + // toDelete should be empty (user had no existing managed auths) + assertThat(toDeleteCaptor.getValue()).isEmpty(); + } + private Authorization authorization(final String groupName, final String roleName) { final Group group = new Group(); group.setName(groupName); @@ -766,6 +1051,12 @@ private Authorization authorization(final String groupName, final String roleNam return new Authorization(group, role); } + private Authorization roleOnlyAuthorization(final String roleName) { + final Role role = new Role(); + role.setName(roleName); + return new Authorization(null, role); + } + private static final String XML_ROLE_CONF = "" + "" @@ -1217,6 +1508,42 @@ private Authorization authorization(final String groupName, final String roleNam + " " + ""; + private static final String XML_DISABLED = "" + + "FULL" + + "false" + + "" + + " truerealm_access.rolesROLECLAIM" + + "" + + "generico" + + "agroup" + + ""; + + private static final String XML_ROLE_CLAIM_NONE = "" + + " NONE" + + " true" + + "" + + " " + + " true" + + " realm_access.roles" + + " ROLECLAIM" + + " " + + "" + + "" + + " default-roles-entando-development" + + " offline_access" + + " uma_authorization" + + " default-roles-entando" + + " " + + " " + + " generico" + + " imported_role2" + + " " + + " " + + " imported_group" + + " imported_group2" + + " " + + ""; + private static final String JWT_NO_ROLE = "{" + " \"header\" : {" + " \"alg\" : \"RS256\"," From 301515efe1829be16eddb58cf1641a1dac492c99 Mon Sep 17 00:00:00 2001 From: "Matteo E. Minnai" Date: Thu, 26 Feb 2026 19:58:05 +0100 Subject: [PATCH 38/44] ESB-950 Code cleaning --- .../authorization/AuthorizationDAO.java | 18 +++++++--- .../KeycloakAuthorizationManager.java | 36 ++----------------- 2 files changed, 15 insertions(+), 39 deletions(-) diff --git a/engine/src/main/java/com/agiletec/aps/system/services/authorization/AuthorizationDAO.java b/engine/src/main/java/com/agiletec/aps/system/services/authorization/AuthorizationDAO.java index b147b0d90..c1e0db138 100644 --- a/engine/src/main/java/com/agiletec/aps/system/services/authorization/AuthorizationDAO.java +++ b/engine/src/main/java/com/agiletec/aps/system/services/authorization/AuthorizationDAO.java @@ -225,7 +225,7 @@ public boolean checkExternalAuthSync(final String username, final Long iat) { String userId = null; try (PreparedStatement selectStmt = conn.prepareStatement( - "SELECT username, iat FROM ext_sync WHERE username = ? FOR UPDATE")) { + QUERY_SYNC_STATUS)) { selectStmt.setString(1, username); try (ResultSet rs = selectStmt.executeQuery()) { @@ -265,7 +265,7 @@ public void externalAuthSync(final String username, final Long iat, String usernameTracked = null; try (PreparedStatement selectStmt = conn.prepareStatement( - "SELECT username, iat FROM ext_sync WHERE username = ? FOR UPDATE")) { + QUERY_SYNC_STATUS)) { selectStmt.setString(1, username); try (ResultSet rs = selectStmt.executeQuery()) { @@ -279,7 +279,7 @@ public void externalAuthSync(final String username, final Long iat, if (usernameTracked == null) { _logger.debug("creating entry for user {}", username); try (PreparedStatement insertStmt = conn.prepareStatement( - "INSERT INTO ext_sync (username, iat) VALUES (?, ?)")) { + CREATE_SYNC_STATUS)) { insertStmt.setString(1, username); insertStmt.setLong(2, iat); insertStmt.executeUpdate(); @@ -293,7 +293,7 @@ public void externalAuthSync(final String username, final Long iat, _logger.debug("updating entry for user {}", username); // Aggiorna iat try (PreparedStatement updateIat = conn.prepareStatement( - "UPDATE ext_sync SET iat = ? WHERE username = ? AND iat < ?" + UPDATE_SYNC_STATUS )) { updateIat.setLong(1, iat); updateIat.setString(2, username); @@ -427,5 +427,13 @@ protected String getMasterTableIdFieldName() { private final String GET_USER_AUTHORIZATIONS = "SELECT groupname, rolename FROM authusergrouprole WHERE username = ? "; - + + public static final String UPDATE_SYNC_STATUS = + "UPDATE ext_sync SET iat = ? WHERE username = ? AND iat < ?"; + + public static final String CREATE_SYNC_STATUS = + "INSERT INTO ext_sync (username, iat) VALUES (?, ?)"; + + public static final String QUERY_SYNC_STATUS = + "SELECT username, iat FROM ext_sync WHERE username = ? FOR UPDATE"; } 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 30c7d87a8..7da612f6e 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 @@ -261,46 +261,14 @@ private void syncAuthorizations(final UserDetails user, final List d.equals(a)); }) .collect(Collectors.toList()); - sillyDebug(user, dynamicAuthorizations, existingAuths, toAdd, toDelete); + // update authorizations if (persist == PersistKind.FULL) { this.authorizationManager.externalAuthSync(user.getUsername(), iat, toAdd, toDelete); - } - + } user.getAuthorizations().removeAll(toDelete); user.addAuthorizations(toAdd); - } - -/**/ - private static void sillyDebug(UserDetails user, List dynamicAuthorizations, - List existingAuths, List toAdd, List toDelete) { - System.out.println("-------------------\n"); - dynamicAuthorizations.forEach(n-> { - final String groupName = n.getGroup() != null ? n.getGroup().getName() : null; - final String roleName = n.getRole() != null ? n.getRole().getName() : null; - - System.out.println("INCOMING " + user.getUsername() + " role " + roleName + " group " + groupName); - }); - existingAuths.forEach(n-> { - final String groupName = n.getGroup() != null ? n.getGroup().getName() : null; - final String roleName = n.getRole() != null ? n.getRole().getName() : null; - - System.out.println("USER " + user.getUsername() + " role " + roleName + " group " + groupName); - }); - toAdd.forEach(n-> { - final String groupName = n.getGroup() != null ? n.getGroup().getName() : null; - final String roleName = n.getRole() != null ? n.getRole().getName() : null; - - System.out.println("ADD " + user.getUsername() + " role " + roleName + " group " + groupName); - }); - toDelete.forEach(d-> { - final String groupName = d.getGroup() != null ? d.getGroup().getName() : null; - final String roleName = d.getRole() != null ? d.getRole().getName() : null; - - System.out.println("DELETE " + user.getUsername() + " role " + roleName + " group " + groupName); - }); } -/**/ /** * Analyze the JWT looking for known mappings to translate into Entando roles From 12780325ef165a1331138b93bd571cdc4d9069b3 Mon Sep 17 00:00:00 2001 From: "Matteo E. Minnai" Date: Thu, 26 Feb 2026 20:17:10 +0100 Subject: [PATCH 39/44] ESB-950 Quality gate --- .../system/services/authorization/AuthorizationDAO.java | 8 +++++--- .../services/authorization/AuthorizationManager.java | 2 +- .../system/services/authorization/IAuthorizationDAO.java | 1 - 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/engine/src/main/java/com/agiletec/aps/system/services/authorization/AuthorizationDAO.java b/engine/src/main/java/com/agiletec/aps/system/services/authorization/AuthorizationDAO.java index c1e0db138..fa8a2cac1 100644 --- a/engine/src/main/java/com/agiletec/aps/system/services/authorization/AuthorizationDAO.java +++ b/engine/src/main/java/com/agiletec/aps/system/services/authorization/AuthorizationDAO.java @@ -188,7 +188,7 @@ private int doDeleteUserAuthorizationByGroupAndRole(final Connection conn, final final boolean hasRoles = roles != null && !roles.isEmpty(); final boolean hasGroups = groups != null && !groups.isEmpty(); - try (PreparedStatement stat = conn.prepareStatement(createSqlForAuthDeletion(username, groups, roles))) { + try (PreparedStatement stat = conn.prepareStatement(createSqlForAuthDeletion(groups, roles))) { // username int index = 1; @@ -346,8 +346,10 @@ private void addAuthorities(final Connection conn, final String username, final )) { int batchSize = 0; + stmt.setString(1, username); + for (Authorization cur : list) { - stmt.setString(1, username); + if (cur.getGroup() != null) { stmt.setString(2, cur.getGroup().getName()); @@ -371,7 +373,7 @@ private void addAuthorities(final Connection conn, final String username, final } } - private String createSqlForAuthDeletion(final String username, final List groups, final List roles) { + private String createSqlForAuthDeletion(final List groups, final List roles) { final StringBuilder sb = new StringBuilder(DELETE_USER_AUTHORIZATIONS); final boolean hasRoles = roles != null && !roles.isEmpty(); final boolean hasGroups = groups != null && !groups.isEmpty(); 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 59c660768..afd3c27f6 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 @@ -652,7 +652,7 @@ public void externalAuthSync(String username, Long iat, List toAd throws EntException { try { this.getAuthorizationDAO().externalAuthSync(username, iat, toAdd, toRemove); - } catch (Throwable t) { + } catch (Exception t) { _logger.error("Error syncing external authorization for user '{}'", username, t); throw new EntException("Error syncing external authorization for user " + username, t); } diff --git a/engine/src/main/java/com/agiletec/aps/system/services/authorization/IAuthorizationDAO.java b/engine/src/main/java/com/agiletec/aps/system/services/authorization/IAuthorizationDAO.java index 747976bb4..d3bba29d6 100644 --- a/engine/src/main/java/com/agiletec/aps/system/services/authorization/IAuthorizationDAO.java +++ b/engine/src/main/java/com/agiletec/aps/system/services/authorization/IAuthorizationDAO.java @@ -15,7 +15,6 @@ import com.agiletec.aps.system.services.group.Group; import com.agiletec.aps.system.services.role.Role; - import java.util.List; import java.util.Map; From 735df98defc493d2635a7fb5813980d08e8fec3d Mon Sep 17 00:00:00 2001 From: "Matteo E. Minnai" Date: Thu, 26 Feb 2026 21:03:21 +0100 Subject: [PATCH 40/44] ESB-950 Quality gate --- .../authorization/AuthorizationManager.java | 2 +- .../services/KeycloakAuthorizationManager.java | 14 ++++---------- .../keycloak/services/oidc/OidcMappingHelper.java | 2 ++ .../services/KeycloakAuthorizationManagerTest.java | 6 +++--- 4 files changed, 10 insertions(+), 14 deletions(-) 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 afd3c27f6..33cd5999f 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 @@ -641,7 +641,7 @@ public void deleteUser(Object key) { public void deleteUserAuthorizationByGroupAndRole(String username, List groups, List roles) throws EntException { try { this.getAuthorizationDAO().deleteUserAuthorizationByGroupAndRole(username, groups, roles); - } catch (Throwable t) { + } catch (Exception t) { _logger.error("Error deleting user authorization by group and role for user '{}'", username, t); throw new EntException("Error deleting user authorization by group and role for user " + username, t); } 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 7da612f6e..3e53a1f45 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 @@ -18,7 +18,6 @@ import com.fasterxml.jackson.dataformat.xml.XmlMapper; import com.google.common.collect.Sets; import java.util.ArrayList; -import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.Objects; @@ -465,7 +464,7 @@ private Authorization parseAuthForRoleGroup(KeycloakUser user, DynamicMappingEle final String groupName = tokens[1]; final String roleName = tokens[0]; - return finalizeAssociation(user, elem, roleName, groupName, false); + return finalizeAssociation(user, roleName, groupName, false); } private Group createTransientGroup(String groupName) { @@ -504,7 +503,7 @@ private List finalizeRoleAssociation(KeycloakUser user, DynamicMa } private Authorization finalizeAssociation(KeycloakUser user, DynamicMappingElement elem, String roleName, String groupName) { - return finalizeAssociation(user, elem, roleName, groupName, true); + return finalizeAssociation(user, roleName, groupName, true); } private boolean isIgnored(String name) { @@ -514,7 +513,7 @@ private boolean isIgnored(String name) { return ignore.contains(name.trim()); } - private Authorization finalizeAssociation(KeycloakUser user, DynamicMappingElement elem, String roleName, String groupName, + private Authorization finalizeAssociation(KeycloakUser user, String roleName, String groupName, boolean createRoleIfMissing) { // is it excluded? if (isIgnored(roleName) || isIgnored(groupName)) { @@ -530,12 +529,7 @@ private Authorization finalizeAssociation(KeycloakUser user, DynamicMappingEleme log.info("Group {} is not managed. Skipping assignment for user {}", groupName, user.getUsername()); return null; } - // further optimization -// if (!isAlreadyAssigned(user, groupName, roleName)) { - return createAuthorization(roleName, groupName, createRoleIfMissing); -// } else { -// return null; -// } + return createAuthorization(roleName, groupName, createRoleIfMissing); } private Authorization createAuthorization(String roleName, String groupName, boolean createRoleIfMissing) { diff --git a/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/oidc/OidcMappingHelper.java b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/oidc/OidcMappingHelper.java index 60cd3ae12..e0db58e5d 100644 --- a/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/oidc/OidcMappingHelper.java +++ b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/oidc/OidcMappingHelper.java @@ -16,6 +16,8 @@ public class OidcMappingHelper { + private OidcMappingHelper() {} + private static final EntLogger log = EntLogFactory.getSanitizedLogger(OidcMappingHelper.class); private static final ObjectMapper mapper = new ObjectMapper(); diff --git a/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java b/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java index 336067fb1..e7accc64c 100644 --- a/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java +++ b/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java @@ -294,7 +294,7 @@ void testDynamicConfigurationGroupOnLoginNoPersist() throws Exception { UserRepresentation userRepresentation = new UserRepresentation(); userRepresentation.setAttributes(Map.of("AD_GROUP", List.of("group"))); -// when(userDetails.getUsername()).thenReturn("testuser"); + when(userDetails.getUserRepresentation()).thenReturn(userRepresentation); manager.init(); @@ -680,7 +680,7 @@ void testAuthAssignmentWhenRoleGroupExistWithPersistAuth() throws Exception { UserRepresentation userRepresentation = new UserRepresentation(); userRepresentation.setAttributes(Map.of("AD_ROLE", List.of("existing_role"))); when(userDetails.getUserRepresentation()).thenReturn(userRepresentation); -// when(userDetails.getUsername()).thenReturn("testuser"); + when(userDetails.getAuthorizations()).thenReturn(new ArrayList<>()); manager.init(); @@ -733,7 +733,7 @@ void testAuthAssignmentWhenRoleExistsAndAddRoleFailsWithPersistAuth() throws Exc UserRepresentation userRepresentation = new UserRepresentation(); userRepresentation.setAttributes(Map.of("AD_ROLE", List.of("conflict_role"))); when(userDetails.getUserRepresentation()).thenReturn(userRepresentation); -// when(userDetails.getUsername()).thenReturn("testuser"); + when(userDetails.getAuthorizations()).thenReturn(new ArrayList<>()); manager.init(); From bda49d90f47b7983fe5fd599987db8a594d7ac8a Mon Sep 17 00:00:00 2001 From: "Matteo E. Minnai" Date: Fri, 27 Feb 2026 07:24:56 +0100 Subject: [PATCH 41/44] ESB-950 Refactored ext_sync table name into authextsync --- .../authorization/AuthorizationDAO.java | 49 ++++++++++++++++--- .../authorization/AuthorizationManager.java | 15 +++++- .../authorization/IAuthorizationDAO.java | 6 ++- .../authorization/IAuthorizationManager.java | 37 ++++++++++++-- .../serv/00000000000004_schemaServ.xml | 4 +- .../TestAuthorizationManager.java | 6 +-- ...ternalSynchronizationAuthorizationDAO.java | 28 +++++------ .../KeycloakAuthorizationManager.java | 2 +- ...ycloakAuthorizationManagerComplexTest.java | 3 +- .../KeycloakAuthorizationManagerTest.java | 6 +-- 10 files changed, 117 insertions(+), 39 deletions(-) diff --git a/engine/src/main/java/com/agiletec/aps/system/services/authorization/AuthorizationDAO.java b/engine/src/main/java/com/agiletec/aps/system/services/authorization/AuthorizationDAO.java index fa8a2cac1..41c3f9ea4 100644 --- a/engine/src/main/java/com/agiletec/aps/system/services/authorization/AuthorizationDAO.java +++ b/engine/src/main/java/com/agiletec/aps/system/services/authorization/AuthorizationDAO.java @@ -22,7 +22,9 @@ import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Types; +import java.time.Instant; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Map; import org.apache.commons.collections.CollectionUtils; @@ -214,7 +216,7 @@ private int doDeleteUserAuthorizationByGroupAndRole(final Connection conn, final // Returns true if the user's external authentication synchronization is up to date @Override - public boolean checkExternalAuthSync(final String username, final Long iat) { + public boolean externalAuthSyncCheck(final String username, final Long iat) { Connection conn = null; PreparedStatement stat = null; @@ -382,7 +384,7 @@ private String createSqlForAuthDeletion(final List groups, final List groups, final List groups, final List toAd } @Override - public boolean checkExternalAuthSync(final String username, final Long iat) throws EntException { + public boolean externalAuthSyncCheck(final String username, final Long iat) throws EntException { try { - return this.getAuthorizationDAO().checkExternalAuthSync(username, iat); + return this.getAuthorizationDAO().externalAuthSyncCheck(username, iat); } catch (Exception t) { _logger.error("Error checking synchronization status for user '{}'", username, t); throw new EntException("Error checking synchronization status for user " + username, t); } } + @Override + public int externalAuthSyncClean(final Instant threshold, final int batchSize) throws EntException { + try { + return this.getAuthorizationDAO().externalAuthSyncClean(threshold, batchSize); + } catch (Exception t) { + _logger.error("Error cleaning synchronization table", t); + throw new EntException("Error cleaning synchronization table", t); + } + } + @Override public List getUsersByRole(IApsAuthority authority, boolean includeAdmin) throws EntException { if (null == authority || !(authority instanceof Role) || null == this.getRoleManager().getRole(authority.getAuthority())) { diff --git a/engine/src/main/java/com/agiletec/aps/system/services/authorization/IAuthorizationDAO.java b/engine/src/main/java/com/agiletec/aps/system/services/authorization/IAuthorizationDAO.java index d3bba29d6..fe2e9d7a9 100644 --- a/engine/src/main/java/com/agiletec/aps/system/services/authorization/IAuthorizationDAO.java +++ b/engine/src/main/java/com/agiletec/aps/system/services/authorization/IAuthorizationDAO.java @@ -15,6 +15,8 @@ import com.agiletec.aps.system.services.group.Group; import com.agiletec.aps.system.services.role.Role; +import java.sql.SQLException; +import java.time.Instant; import java.util.List; import java.util.Map; @@ -40,8 +42,10 @@ public interface IAuthorizationDAO { int deleteUserAuthorizationByGroupAndRole(String username, List groups, List roles); // Returns true if the user's external authentication synchronization is up to date - boolean checkExternalAuthSync(String username, Long iat); + boolean externalAuthSyncCheck(String username, Long iat); void externalAuthSync(String username, Long iat, List toAdd, List toRemove); + + int externalAuthSyncClean(Instant threshold, int batchSize) throws SQLException; } diff --git a/engine/src/main/java/com/agiletec/aps/system/services/authorization/IAuthorizationManager.java b/engine/src/main/java/com/agiletec/aps/system/services/authorization/IAuthorizationManager.java index 982a31b87..98ddaf348 100644 --- a/engine/src/main/java/com/agiletec/aps/system/services/authorization/IAuthorizationManager.java +++ b/engine/src/main/java/com/agiletec/aps/system/services/authorization/IAuthorizationManager.java @@ -13,6 +13,7 @@ */ package com.agiletec.aps.system.services.authorization; +import java.time.Instant; import java.util.List; import java.util.Set; @@ -181,11 +182,6 @@ public interface IAuthorizationManager { void deleteUserAuthorizationByGroupAndRole(String username, List groups, List roles) throws EntException; - void externalAuthSync(String username, Long iat, List toAdd, List toRemove) - throws EntException; - - boolean checkExternalAuthSync(String username, Long iat) throws EntException; - public List getUsersByRole(IApsAuthority authority, boolean includeAdmin) throws EntException; public List getUsersByRole(String roleName, boolean includeAdmin) throws EntException; @@ -194,4 +190,35 @@ void externalAuthSync(String username, Long iat, List toAdd, List public List getUsersByGroup(String groupName, boolean includeAdmin) throws EntException; + /** + * Synchronizes the user's authorizations and tracks the change timestamp. + * + * @param username The username of the user to synchronize. + * @param iat The timestamp of the current synchronization (as found in the JWT) + * @param toAdd The authorizations to add. + * @param toRemove The authorizations to remove. + * @throws EntException If an error occurs during synchronization. + */ + void externalAuthSync(String username, Long iat, List toAdd, List toRemove) + throws EntException; + + /** + * Checks if the user's last synchronization is within the threshold. + * @param username The username of the user to check. + * @param iat The timestamp of the current synchronization (as found in the JWT) + * @return True if the user's last synchronization is within the threshold, false otherwise. + * @throws EntException If an error occurs during the check. + */ + boolean externalAuthSyncCheck(String username, Long iat) throws EntException; + + /** + * Cleans up old synchronization records. + * + * @param threshold The timestamp threshold for records to be cleaned. + * @param batchSize The number of records to process in each batch. + * @return int the number of deleted records. + * @throws EntException If an error occurs during cleanup. + */ + int externalAuthSyncClean(Instant threshold, int batchSize) throws EntException; + } diff --git a/engine/src/main/resources/liquibase/serv/00000000000004_schemaServ.xml b/engine/src/main/resources/liquibase/serv/00000000000004_schemaServ.xml index 2daebddae..51222a11e 100644 --- a/engine/src/main/resources/liquibase/serv/00000000000004_schemaServ.xml +++ b/engine/src/main/resources/liquibase/serv/00000000000004_schemaServ.xml @@ -7,7 +7,7 @@ - + @@ -17,7 +17,7 @@ - + diff --git a/engine/src/test/java/com/agiletec/aps/system/services/authorization/TestAuthorizationManager.java b/engine/src/test/java/com/agiletec/aps/system/services/authorization/TestAuthorizationManager.java index 1855310e3..be4693162 100644 --- a/engine/src/test/java/com/agiletec/aps/system/services/authorization/TestAuthorizationManager.java +++ b/engine/src/test/java/com/agiletec/aps/system/services/authorization/TestAuthorizationManager.java @@ -465,11 +465,11 @@ void testExternalAuthSync() throws Throwable { // Test checkExternalAuthSync // iat uguale all'ultimo (2000) -> deve ritornare true (sincronizzato) - assertTrue(this.authorizationManager.checkExternalAuthSync(username, higherIat)); + assertTrue(this.authorizationManager.externalAuthSyncCheck(username, higherIat)); // iat minore (1500) -> deve ritornare true (già sincronizzato con un iat superiore) - assertTrue(this.authorizationManager.checkExternalAuthSync(username, 1500L)); + assertTrue(this.authorizationManager.externalAuthSyncCheck(username, 1500L)); // iat maggiore (3000) -> deve ritornare false (necessita sincronizzazione) - assertFalse(this.authorizationManager.checkExternalAuthSync(username, 3000L)); + assertFalse(this.authorizationManager.externalAuthSyncCheck(username, 3000L)); } finally { UserDetails user = this.userManager.getUser(username); diff --git a/engine/src/test/java/com/agiletec/aps/system/services/authorization/TestExternalSynchronizationAuthorizationDAO.java b/engine/src/test/java/com/agiletec/aps/system/services/authorization/TestExternalSynchronizationAuthorizationDAO.java index b28cf7261..15cd071c7 100644 --- a/engine/src/test/java/com/agiletec/aps/system/services/authorization/TestExternalSynchronizationAuthorizationDAO.java +++ b/engine/src/test/java/com/agiletec/aps/system/services/authorization/TestExternalSynchronizationAuthorizationDAO.java @@ -27,22 +27,22 @@ void setUpMethod() throws Exception { private void cleanSyncTable() throws Exception { DataSource dataSource = (DataSource) this.getApplicationContext().getBean("servDataSource"); try (Connection conn = dataSource.getConnection()) { - try (PreparedStatement stat = conn.prepareStatement("DELETE FROM ext_sync")) { + try (PreparedStatement stat = conn.prepareStatement("DELETE FROM authusersextsync")) { stat.executeUpdate(); } } } @Test - void testCheckExternalAuthSync_NoUser() { + void testExternalAuthSync_Check_NoUser() { String username = "testUser"; Long iat = 1000L; // Se l'utente non esiste, deve restituire false - assertFalse(authorizationDAO.checkExternalAuthSync(username, iat)); + assertFalse(authorizationDAO.externalAuthSyncCheck(username, iat)); } @Test - void testCheckExternalAuthSync_OldIat() throws Exception { + void testExternalAuthSync_Check_OldIat() throws Exception { String username = "testUser"; Long currentIat = 1000L; Long newIat = 2000L; @@ -50,7 +50,7 @@ void testCheckExternalAuthSync_OldIat() throws Exception { // Inserisco manualmente un record DataSource dataSource = (DataSource) this.getApplicationContext().getBean("servDataSource"); try (Connection conn = dataSource.getConnection()) { - try (PreparedStatement stat = conn.prepareStatement("INSERT INTO ext_sync (username, iat) VALUES (?, ?)")) { + try (PreparedStatement stat = conn.prepareStatement("INSERT INTO authusersextsync (username, iat) VALUES (?, ?)")) { stat.setString(1, username); stat.setLong(2, currentIat); stat.executeUpdate(); @@ -58,18 +58,18 @@ void testCheckExternalAuthSync_OldIat() throws Exception { } // Se l'IAT fornito è maggiore di quello salvato, deve restituire false - assertFalse(authorizationDAO.checkExternalAuthSync(username, newIat)); + assertFalse(authorizationDAO.externalAuthSyncCheck(username, newIat)); } @Test - void testCheckExternalAuthSync_SameIat() throws Exception { + void testExternalAuthSync_Check_SameIat() throws Exception { String username = "testUser"; Long currentIat = 1000L; // Inserisco manualmente un record DataSource dataSource = (DataSource) this.getApplicationContext().getBean("servDataSource"); try (Connection conn = dataSource.getConnection()) { - try (PreparedStatement stat = conn.prepareStatement("INSERT INTO ext_sync (username, iat) VALUES (?, ?)")) { + try (PreparedStatement stat = conn.prepareStatement("INSERT INTO authusersextsync (username, iat) VALUES (?, ?)")) { stat.setString(1, username); stat.setLong(2, currentIat); stat.executeUpdate(); @@ -77,11 +77,11 @@ void testCheckExternalAuthSync_SameIat() throws Exception { } // Se l'IAT è uguale, deve restituire true (sincronizzato) - assertTrue(authorizationDAO.checkExternalAuthSync(username, currentIat)); + assertTrue(authorizationDAO.externalAuthSyncCheck(username, currentIat)); } @Test - void testCheckExternalAuthSync_NewerIat() throws Exception { + void testExternalAuthSync_Check_NewerIat() throws Exception { String username = "testUser"; Long currentIat = 2000L; Long oldIat = 1000L; @@ -89,7 +89,7 @@ void testCheckExternalAuthSync_NewerIat() throws Exception { // Inserisco manualmente un record DataSource dataSource = (DataSource) this.getApplicationContext().getBean("servDataSource"); try (Connection conn = dataSource.getConnection()) { - try (PreparedStatement stat = conn.prepareStatement("INSERT INTO ext_sync (username, iat) VALUES (?, ?)")) { + try (PreparedStatement stat = conn.prepareStatement("INSERT INTO authusersextsync (username, iat) VALUES (?, ?)")) { stat.setString(1, username); stat.setLong(2, currentIat); stat.executeUpdate(); @@ -97,7 +97,7 @@ void testCheckExternalAuthSync_NewerIat() throws Exception { } // Se l'IAT fornito è minore di quello salvato, deve restituire true (abbiamo già dati più recenti) - assertTrue(authorizationDAO.checkExternalAuthSync(username, oldIat)); + assertTrue(authorizationDAO.externalAuthSyncCheck(username, oldIat)); } @Test @@ -110,7 +110,7 @@ void testExternalAuthSync_Insert() throws Exception { // Verifico che sia stato inserito DataSource dataSource = (DataSource) getApplicationContext().getBean("servDataSource"); try (Connection conn = dataSource.getConnection()) { - try (PreparedStatement stat = conn.prepareStatement("SELECT iat FROM ext_sync WHERE username = ?")) { + try (PreparedStatement stat = conn.prepareStatement("SELECT iat FROM authusersextsync WHERE username = ?")) { stat.setString(1, username); try (var rs = stat.executeQuery()) { assertTrue(rs.next()); @@ -135,7 +135,7 @@ void testExternalAuthSync_Update() throws Exception { // Verifico che sia stato aggiornato DataSource dataSource = (DataSource) getApplicationContext().getBean("servDataSource"); try (Connection conn = dataSource.getConnection()) { - try (PreparedStatement stat = conn.prepareStatement("SELECT iat FROM ext_sync WHERE username = ?")) { + try (PreparedStatement stat = conn.prepareStatement("SELECT iat FROM authusersextsync WHERE username = ?")) { stat.setString(1, username); try (var rs = stat.executeQuery()) { assertTrue(rs.next()); 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 3e53a1f45..dc2815515 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 @@ -199,7 +199,7 @@ public void processNewUser(final UserDetails user, final String token, final boo } // abort if already synced - if (iat > 0 && authorizationManager.checkExternalAuthSync(user.getUsername(), iat)) { + if (iat > 0 && authorizationManager.externalAuthSyncCheck(user.getUsername(), iat)) { log.debug("user {} already synced (iat: {})", user.getUsername(), iat); return; } diff --git a/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerComplexTest.java b/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerComplexTest.java index 847719ba6..a7da3622e 100644 --- a/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerComplexTest.java +++ b/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerComplexTest.java @@ -25,7 +25,6 @@ import java.util.List; import java.util.Map; import org.entando.entando.ent.exception.EntException; -import org.entando.entando.keycloak.services.oidc.OidcMappingHelper; import org.entando.entando.keycloak.services.oidc.model.KeycloakUser; import org.entando.entando.keycloak.services.oidc.model.UserRepresentation; import org.junit.jupiter.api.BeforeEach; @@ -63,7 +62,7 @@ public void setUp() { manager = new KeycloakAuthorizationManager(configuration, authorizationManager, groupManager, roleManager, configManager); lenient().when(configuration.getDefaultAuthorizations()).thenReturn(""); try { - lenient().when(authorizationManager.checkExternalAuthSync(anyString(), any(Long.class))).thenReturn(false); + lenient().when(authorizationManager.externalAuthSyncCheck(anyString(), any(Long.class))).thenReturn(false); } catch (EntException e) { // ignore } diff --git a/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java b/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java index e7accc64c..b2597213f 100644 --- a/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java +++ b/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java @@ -51,7 +51,7 @@ class KeycloakAuthorizationManagerTest { @BeforeEach public void setUp() throws EntException { manager = new KeycloakAuthorizationManager(configuration, authorizationManager, groupManager, roleManager, configManager); - lenient().when(authorizationManager.checkExternalAuthSync(anyString(), any(Long.class))).thenReturn(false); + lenient().when(authorizationManager.externalAuthSyncCheck(anyString(), any(Long.class))).thenReturn(false); } @Test @@ -899,7 +899,7 @@ void testProcessNewUserAlreadySyncedSkipsProcessing() throws Exception { when(userDetails.getUsername()).thenReturn("testuser"); // Override the lenient stub: checkExternalAuthSync returns true - when(authorizationManager.checkExternalAuthSync(eq("testuser"), anyLong())).thenReturn(true); + when(authorizationManager.externalAuthSyncCheck(eq("testuser"), anyLong())).thenReturn(true); manager.init(); manager.processNewUser(userDetails, JWT, false); @@ -932,7 +932,7 @@ void testRefreshConfigurationSuccess() throws Exception { manager.refreshConfiguration(); // Reset the checkExternalAuthSync to allow re-processing - when(authorizationManager.checkExternalAuthSync(anyString(), anyLong())).thenReturn(false); + when(authorizationManager.externalAuthSyncCheck(anyString(), anyLong())).thenReturn(false); manager.processNewUser(userDetails, JWT, false); From 2146b45ae3469e0d8e8aae9f55c451c1d2f3659b Mon Sep 17 00:00:00 2001 From: "Matteo E. Minnai" Date: Fri, 27 Feb 2026 08:12:20 +0100 Subject: [PATCH 42/44] ESB-950 Added clean-up mechanism --- .../TestAuthorizationManager.java | 27 +++++++++++ ...ternalSynchronizationAuthorizationDAO.java | 46 +++++++++++++++++++ keycloak-plugin/README.md | 4 +- .../KeycloakAuthorizationManager.java | 31 ++++++++++--- .../spring/plugins/keycloak/aps/keycloak.xml | 10 +++- .../KeycloakAuthorizationManagerTest.java | 22 +++++++++ 6 files changed, 132 insertions(+), 8 deletions(-) diff --git a/engine/src/test/java/com/agiletec/aps/system/services/authorization/TestAuthorizationManager.java b/engine/src/test/java/com/agiletec/aps/system/services/authorization/TestAuthorizationManager.java index be4693162..374fd9fd0 100644 --- a/engine/src/test/java/com/agiletec/aps/system/services/authorization/TestAuthorizationManager.java +++ b/engine/src/test/java/com/agiletec/aps/system/services/authorization/TestAuthorizationManager.java @@ -31,6 +31,7 @@ import com.agiletec.aps.system.services.user.User; import com.agiletec.aps.system.services.user.UserDetails; +import java.time.Instant; import java.util.ArrayList; import java.util.List; import org.junit.jupiter.api.Assertions; @@ -479,6 +480,32 @@ void testExternalAuthSync() throws Throwable { } } + @Test + void testExternalAuthSyncClean() throws Throwable { + String username = "UserForSyncCleanTest"; + String password = "PasswordForSyncCleanTest"; + this.addUserForTest(username, password); + try { + long iat = 1000L; + this.authorizationManager.externalAuthSync(username, iat, new ArrayList<>(), new ArrayList<>()); + assertTrue(this.authorizationManager.externalAuthSyncCheck(username, iat)); + + // Pulizia con soglia superiore a 1000 + Instant threshold = Instant.ofEpochSecond(2000); + int deleted = this.authorizationManager.externalAuthSyncClean(threshold, 100); + assertTrue(deleted >= 1); + + // Adesso l'utente non dovrebbe più essere sincronizzato (record cancellato) + assertFalse(this.authorizationManager.externalAuthSyncCheck(username, iat)); + + } finally { + UserDetails user = this.userManager.getUser(username); + if (null != user) { + this.userManager.removeUser(user); + } + } + } + private void addUserForTest(String username, String password) throws Throwable { User user = new User(); user.setUsername(username); diff --git a/engine/src/test/java/com/agiletec/aps/system/services/authorization/TestExternalSynchronizationAuthorizationDAO.java b/engine/src/test/java/com/agiletec/aps/system/services/authorization/TestExternalSynchronizationAuthorizationDAO.java index 15cd071c7..8e7c14df5 100644 --- a/engine/src/test/java/com/agiletec/aps/system/services/authorization/TestExternalSynchronizationAuthorizationDAO.java +++ b/engine/src/test/java/com/agiletec/aps/system/services/authorization/TestExternalSynchronizationAuthorizationDAO.java @@ -7,6 +7,7 @@ import com.agiletec.aps.BaseTestCase; import java.sql.Connection; import java.sql.PreparedStatement; +import java.time.Instant; import javax.sql.DataSource; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -33,6 +34,51 @@ private void cleanSyncTable() throws Exception { } } + @Test + void testExternalAuthSyncClean() throws Exception { + String user1 = "user1"; + String user2 = "user2"; + String user3 = "user3"; + Long iat1 = 1000L; + Long iat2 = 2000L; + Long iat3 = 3000L; + + // Inserisco manualmente tre record + DataSource dataSource = (DataSource) this.getApplicationContext().getBean("servDataSource"); + try (Connection conn = dataSource.getConnection()) { + try (PreparedStatement stat = conn.prepareStatement("INSERT INTO authusersextsync (username, iat) VALUES (?, ?)")) { + stat.setString(1, user1); + stat.setLong(2, iat1); + stat.addBatch(); + stat.setString(1, user2); + stat.setLong(2, iat2); + stat.addBatch(); + stat.setString(1, user3); + stat.setLong(2, iat3); + stat.addBatch(); + stat.executeBatch(); + } + } + + // Soglia a 2500 (in secondi) + Instant threshold = Instant.ofEpochSecond(2500); + int deleted = authorizationDAO.externalAuthSyncClean(threshold, 100); + + // Dovrebbe aver eliminato user1 (1000) e user2 (2000) + assertEquals(2, deleted); + + // Verifico che sia rimasto solo user3 + try (Connection conn = dataSource.getConnection()) { + try (PreparedStatement stat = conn.prepareStatement("SELECT username FROM authusersextsync")) { + try (var rs = stat.executeQuery()) { + assertTrue(rs.next()); + assertEquals(user3, rs.getString("username")); + assertFalse(rs.next()); + } + } + } + } + @Test void testExternalAuthSync_Check_NoUser() { String username = "testUser"; diff --git a/keycloak-plugin/README.md b/keycloak-plugin/README.md index 52676521c..74943c7d8 100644 --- a/keycloak-plugin/README.md +++ b/keycloak-plugin/README.md @@ -26,7 +26,9 @@ This plugin doesn't come with Role and Group management, because Entando Core ro >- `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 * * * * *` +>- `KC_CONFIG_REFRESH`: specifies the refresh period -Chron style!- of the dynamic configuration used to assign authorizations to the loggin-in users. The default is `0 * * * * *` +>- `KC_SYNC_CLEAN`: Specify — in cron style — the periodicity of the internal synchronization table cleanup. The default is `0 0 0/4 * * *` +>- `KC_SYNC_BATCH_SIZE`: Specify the batch size for the internal synchronization table cleanup. The default is `100` ## Installing 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 dc2815515..1288eaffd 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 @@ -17,6 +17,7 @@ import com.agiletec.aps.system.services.user.UserDetails; import com.fasterxml.jackson.dataformat.xml.XmlMapper; import com.google.common.collect.Sets; +import java.time.Instant; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -61,6 +62,8 @@ public class KeycloakAuthorizationManager extends AbstractService { private final transient Lock readLock = configUpdateLock.readLock(); private final transient Lock writeLock = configUpdateLock.writeLock(); + private int cleanBatchSize; + @Autowired public KeycloakAuthorizationManager(final KeycloakConfiguration configuration, final AuthorizationManager authorizationManager, @@ -113,15 +116,15 @@ public void init() throws Exception { log.debug("{} dynamic auth mapping found, {} profileMappings", dynConf.mapping.size(), profileMappings.size()); } - ignore = Optional.ofNullable(dynConf.exclusions) + ignore = ofNullable(dynConf.exclusions) .orElse(List.of()); - roles = Optional.ofNullable(dynConf.roles) + roles = ofNullable(dynConf.roles) .orElseGet(List::of); - groups = Optional.ofNullable(dynConf.groups) + groups = ofNullable(dynConf.groups) .orElse(List.of()); - enabled = Optional.ofNullable(dynConf.enabled) + enabled = ofNullable(dynConf.enabled) .orElse(false); - persist = Optional.ofNullable(dynConf.persist) + persist = ofNullable(dynConf.persist) .orElse(PersistKind.FULL); } } @@ -154,6 +157,19 @@ public void refreshConfiguration() { } } + public void cleanSyncData() { + try { + if (!this.enabled) return; + final Instant tenMinutesAgo = Instant.now().minusSeconds(600); + + log.info("Cleaning sync data older than {} with a batch size of {}", tenMinutesAgo, cleanBatchSize); + authorizationManager.externalAuthSyncClean(tenMinutesAgo, cleanBatchSize); + log.info("Cleaning completed successfully"); + } catch (Exception e) { + log.error("Error refreshing dynamic mapping configuration", e); + } + } + /** * Check whether the dynamic configuration element provided is valid * @param elem the single dynamic mapping element to validate @@ -245,7 +261,7 @@ private void syncAuthorizations(final UserDetails user, final List existingAuths = Optional.ofNullable(user.getAuthorizations()) + List existingAuths = ofNullable(user.getAuthorizations()) .orElse(List.of()) .stream() .filter(a -> (a.getGroup() != null && groups.contains(a.getGroup().getName()) @@ -614,4 +630,7 @@ private List finalizeGroupAssociation(KeycloakUser user, DynamicM return result; } + public void setCleanBatchSize(Integer cleanBatchSize) { + this.cleanBatchSize = cleanBatchSize; + } } diff --git a/keycloak-plugin/src/main/resources/spring/plugins/keycloak/aps/keycloak.xml b/keycloak-plugin/src/main/resources/spring/plugins/keycloak/aps/keycloak.xml index dc9f3a1a5..e286d57c2 100644 --- a/keycloak-plugin/src/main/resources/spring/plugins/keycloak/aps/keycloak.xml +++ b/keycloak-plugin/src/main/resources/spring/plugins/keycloak/aps/keycloak.xml @@ -9,10 +9,17 @@ + + cron="${KC_CONFIG_REFRESH:0 * * * * *}" /> + + + + diff --git a/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java b/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java index b2597213f..9abca2bc5 100644 --- a/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java +++ b/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java @@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; @@ -1057,6 +1058,27 @@ private Authorization roleOnlyAuthorization(final String roleName) { return new Authorization(null, role); } + @Test + void testCleanSyncData() throws Exception { + when(configManager.getConfigItem("dynamicAuthMapping")).thenReturn("true"); + manager.init(); + manager.setCleanBatchSize(100); + + manager.cleanSyncData(); + + verify(authorizationManager, times(1)).externalAuthSyncClean(any(java.time.Instant.class), eq(100)); + } + + @Test + void testCleanSyncDataDisabled() throws Exception { + when(configManager.getConfigItem("dynamicAuthMapping")).thenReturn("false"); + manager.init(); + + manager.cleanSyncData(); + + verify(authorizationManager, never()).externalAuthSyncClean(any(java.time.Instant.class), anyInt()); + } + private static final String XML_ROLE_CONF = "" + "" From 3f2a7631e8c2e13a5c191b7ff819328183552401 Mon Sep 17 00:00:00 2001 From: "Matteo E. Minnai" Date: Fri, 27 Feb 2026 09:16:08 +0100 Subject: [PATCH 43/44] ESB-950 Fix schema-location --- .../services/authorization/AuthorizationManager.java | 1 - .../resources/spring/plugins/keycloak/aps/keycloak.xml | 9 +++++---- 2 files changed, 5 insertions(+), 5 deletions(-) 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 5d7f03e69..823f7b0f6 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 @@ -763,7 +763,6 @@ public List getGroupUtilizers(String groupName) throws EntException { return this.getUsersByGroup(groupName, false); } - protected IAuthorizationDAO getAuthorizationDAO() { return _authorizationDAO; } diff --git a/keycloak-plugin/src/main/resources/spring/plugins/keycloak/aps/keycloak.xml b/keycloak-plugin/src/main/resources/spring/plugins/keycloak/aps/keycloak.xml index e286d57c2..5dbca27fd 100644 --- a/keycloak-plugin/src/main/resources/spring/plugins/keycloak/aps/keycloak.xml +++ b/keycloak-plugin/src/main/resources/spring/plugins/keycloak/aps/keycloak.xml @@ -4,6 +4,7 @@ xmlns:context="http://www.springframework.org/schema/context" xmlns:task="http://www.springframework.org/schema/task" xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd + http://www.springframework.org/schema/task http://www.springframework.org/schema/task/spring-task.xsd http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd http://www.springframework.org/schema/task http://www.springframework.org/schema/task/spring-task.xsd"> @@ -34,7 +35,7 @@ + class="org.entando.entando.keycloak.services.KeycloakConfiguration"> @@ -46,7 +47,7 @@ + class="org.entando.entando.keycloak.adapter.AuthenticationProviderManagerAdapter"> @@ -57,7 +58,7 @@ + class="org.entando.entando.keycloak.adapter.UserManagerAdapter"> @@ -80,7 +81,7 @@ + class="org.entando.entando.keycloak.adapter.EntandoOauth2InterceptorAdapter"> From cc4b464030a29d6fbbc4c23169dad58ef07ef687 Mon Sep 17 00:00:00 2001 From: "Matteo E. Minnai" Date: Fri, 27 Feb 2026 10:38:20 +0100 Subject: [PATCH 44/44] ESB-950 Safety net for the admin user --- .../KeycloakAuthorizationManager.java | 3 +++ .../KeycloakAuthorizationManagerTest.java | 20 +++++++++++++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) 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 1288eaffd..cad27ba98 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,5 +1,6 @@ package org.entando.entando.keycloak.services; +import static com.agiletec.aps.system.SystemConstants.ADMIN_USER_NAME; import static java.util.Optional.ofNullable; import static org.entando.entando.keycloak.services.mapping.DynamicMappingKind.GROUP; import static org.entando.entando.keycloak.services.mapping.DynamicMappingKind.ROLEGROUP; @@ -198,6 +199,8 @@ private boolean isValid(DynamicMappingElement elem) { public void processNewUser(final UserDetails user, final String token, final boolean decode) { processNewUser(user); if (!enabled) return; + // safety net! Admin is exempted from group and roles assignment + if (ADMIN_USER_NAME.equals(user.getUsername())) return; readLock.lock(); try { // Authorizations coming from dynamic mapping (that is, external sources) diff --git a/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java b/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java index 9abca2bc5..3dcd425ba 100644 --- a/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java +++ b/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java @@ -1,5 +1,6 @@ package org.entando.entando.keycloak.services; +import static com.agiletec.aps.system.SystemConstants.ADMIN_USER_NAME; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -563,6 +564,21 @@ void testDynamicConfigurationRoleGroupOnLoginFromJwtNoPersist() throws Exception }); } + @Test + void testDynamicConfigurationRoleGroupOnLoginForAdmin() throws Exception { + when(configuration.getDefaultAuthorizations()).thenReturn(null); + when(userDetails.getUsername()).thenReturn(ADMIN_USER_NAME); + when(configManager.getConfigItem(anyString())).thenReturn(XML_ROLEGROUP_CLAIM_AUTH); + + manager.init(); + + manager.processNewUser(userDetails, JWT_ROLEGROUP, false); + // Per sicurezza l'utente admin non viene MAI modificato + verify(authorizationManager, never()).addUserAuthorization(eq(ADMIN_USER_NAME), any()); + verify(userDetails, never()).addAuthorizations(anyList()); + verify(authorizationManager, never()).externalAuthSync(eq(ADMIN_USER_NAME), anyLong(), anyList(), anyList()); + } + @Test void testDynamicConfigurationWithIgnoredRoles() throws Exception { when(configuration.getDefaultAuthorizations()).thenReturn(null); @@ -687,7 +703,7 @@ void testAuthAssignmentWhenRoleGroupExistWithPersistAuth() throws Exception { manager.init(); manager.processNewUser(userDetails, null, false); - // Verifichiamo che l'autorizzazione sia stata aggiunta all'utente + // Verifica che l'autorizzazione sia stata aggiunta all'utente @SuppressWarnings("unchecked") ArgumentCaptor> listCaptor = ArgumentCaptor.forClass(List.class); verify(userDetails, times(1)).addAuthorizations(listCaptor.capture()); @@ -695,7 +711,7 @@ void testAuthAssignmentWhenRoleGroupExistWithPersistAuth() throws Exception { assertThat(captured).hasSize(1); assertThat(captured.get(0).getRole().getName()).isEqualTo("existing_role"); - // Verifichiamo che non sia stata chiamata la persistenza + // Verifica che non sia stata chiamata la persistenza verify(authorizationManager, never()).addUserAuthorization(anyString(), any()); }