diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 686e59b407..000a13a3d4 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 @@ -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,8 +112,13 @@ 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 + run: | + unset DOCKER_API_VERSION + .github/test-and-scan.sh - name: Save the test report if: failure() @@ -129,7 +137,7 @@ jobs: contents: read pull-requests: write - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 needs: build steps: diff --git a/Dockerfile.tomcat b/Dockerfile.tomcat index fc3a0bb4f2..4b4e9b42b4 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/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 75199820e8..73c63ad376 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/authorization/AuthorizationDAO.java b/engine/src/main/java/com/agiletec/aps/system/services/authorization/AuthorizationDAO.java index 8ad9c96b58..41c3f9ea4a 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,26 +17,30 @@ 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.time.Instant; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Map; - import org.apache.commons.collections.CollectionUtils; -import org.entando.entando.ent.util.EntLogging.EntLogger; +import org.apache.commons.lang3.StringUtils; import org.entando.entando.ent.util.EntLogging.EntLogFactory; +import org.entando.entando.ent.util.EntLogging.EntLogger; /** * @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; @@ -157,7 +161,282 @@ public List getUsersByAuthorities(List groupNames, List } return super.searchId(filters); } - + + @Override + 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; + + try { + conn = this.getConnection(); + conn.setAutoCommit(false); + + 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(); + + try (PreparedStatement stat = conn.prepareStatement(createSqlForAuthDeletion(groups, roles))) { + + // 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); + } + } + 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 externalAuthSyncCheck(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( + QUERY_SYNC_STATUS)) { + 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 oldIat = null; + String usernameTracked = null; + + try (PreparedStatement selectStmt = conn.prepareStatement( + QUERY_SYNC_STATUS)) { + selectStmt.setString(1, username); + + try (ResultSet rs = selectStmt.executeQuery()) { + if (rs.next()) { + usernameTracked = rs.getString("username"); + oldIat = rs.getLong("iat"); + } + } + } + + if (usernameTracked == null) { + _logger.debug("creating entry for user {}", username); + try (PreparedStatement insertStmt = conn.prepareStatement( + CREATE_SYNC_STATUS)) { + insertStmt.setString(1, username); + insertStmt.setLong(2, iat); + insertStmt.executeUpdate(); + } + + // 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_SYNC_STATUS + )) { + updateIat.setLong(1, iat); + updateIat.setString(2, username); + 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 { + _logger.debug("no need to sync {}", username); + } + conn.commit(); + } catch (Exception e) { + this.executeRollback(conn); + 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; + + stmt.setString(1, username); + + for (Authorization cur : list) { + + + 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 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(); + + sb.append(" AND ("); // apertura AND + + if (hasGroups) { + final String placeholders = String.join(", ", + Collections.nCopies(groups.size(), "?")); + + sb.append("groupname IN ( "); + sb.append(placeholders); + sb.append(") "); // chiusura groupname + // append OR if needed + if (hasRoles) { + sb.append("OR "); + } + } + if (hasRoles) { + final String placeholders = String.join(", ", + Collections.nCopies(roles.size(), "?")); + sb.append("rolename IN ( "); + sb.append(placeholders); + sb.append(") "); // chiusura rolename + } + sb.append(")"); // chiusura AND + return sb.toString(); + } + + @Override + public int externalAuthSyncClean(Instant threshold, int batchSize) throws SQLException { + Connection conn = null; + final long epochSeconds = threshold.getEpochSecond(); + + try { + conn = this.getConnection(); + conn.setAutoCommit(false); + + String sql = DELETE_SYNC_STATUS; + + try (PreparedStatement stat = conn.prepareStatement(sql)) { + + stat.setLong(1, epochSeconds); + stat.setMaxRows(batchSize); + + int deleted = stat.executeUpdate(); + conn.commit(); + + return deleted; + } + } catch (Exception e) { + this.executeRollback(conn); + _logger.error("Error cleaning synchronization status for threshold '{}'", threshold, e); + } finally { + this.closeConnection(conn); + } + return 0; + } + + + @Override protected String getTableFieldName(String metadataFieldKey) { return metadataFieldKey; @@ -184,5 +463,16 @@ protected String getMasterTableIdFieldName() { private final String GET_USER_AUTHORIZATIONS = "SELECT groupname, rolename FROM authusergrouprole WHERE username = ? "; - + + public static final String UPDATE_SYNC_STATUS = + "UPDATE authusersextsync SET iat = ? WHERE username = ? AND iat < ?"; + + public static final String CREATE_SYNC_STATUS = + "INSERT INTO authusersextsync (username, iat) VALUES (?, ?)"; + + public static final String QUERY_SYNC_STATUS = + "SELECT username, iat FROM authusersextsync WHERE username = ? FOR UPDATE"; + + public static final String DELETE_SYNC_STATUS = + "DELETE FROM authusersextsync WHERE iat < ?"; } 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 cf868a58bc..823f7b0f61 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 @@ -13,6 +13,7 @@ */ package com.agiletec.aps.system.services.authorization; +import java.time.Instant; import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; @@ -65,7 +66,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); @@ -637,6 +638,47 @@ public void deleteUser(Object key) { } } + @Override + public void deleteUserAuthorizationByGroupAndRole(String username, List groups, List roles) throws EntException { + try { + this.getAuthorizationDAO().deleteUserAuthorizationByGroupAndRole(username, groups, roles); + } 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); + } + } + + @Override + public void externalAuthSync(String username, Long iat, List toAdd, List toRemove) + throws EntException { + try { + this.getAuthorizationDAO().externalAuthSync(username, iat, toAdd, toRemove); + } catch (Exception 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 externalAuthSyncCheck(final String username, final Long iat) throws EntException { + try { + 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 979711c6de..fe2e9d7a9a 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,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; @@ -37,4 +38,14 @@ public interface IAuthorizationDAO { public void deleteUserAuthorizations(String username); public List getUsersByAuthorities(List groupNames, List roleNames); -} \ No newline at end of file + + int deleteUserAuthorizationByGroupAndRole(String username, List groups, List roles); + + // Returns true if the user's external authentication synchronization is up to date + 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 6ade341977..98ddaf3487 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; @@ -177,7 +178,10 @@ public interface IAuthorizationManager { public List getUsersByAuthority(IApsAuthority authority, boolean includeAdmin) throws EntException; public List getUsersByAuthorities(String groupName, String roleName, 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; @@ -185,5 +189,36 @@ 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 + + /** + * 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/java/com/agiletec/aps/system/services/group/Group.java b/engine/src/main/java/com/agiletec/aps/system/services/group/Group.java index 61fc01c0f5..bebdea3d35 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/engine/src/main/resources/liquibase/changeSetServ.xml b/engine/src/main/resources/liquibase/changeSetServ.xml index 4fa32d2d8e..8917d6d08c 100644 --- a/engine/src/main/resources/liquibase/changeSetServ.xml +++ b/engine/src/main/resources/liquibase/changeSetServ.xml @@ -17,4 +17,8 @@ + + + + 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 0000000000..552e4361d9 --- /dev/null +++ b/engine/src/main/resources/liquibase/serv/00000000000003_schemaServ.xml @@ -0,0 +1,14 @@ + + + + + + + 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 0000000000..51222a11e9 --- /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/AuthorizationManagerTest.java b/engine/src/test/java/com/agiletec/aps/system/services/authorization/AuthorizationManagerTest.java new file mode 100644 index 0000000000..a914fb6b40 --- /dev/null +++ b/engine/src/test/java/com/agiletec/aps/system/services/authorization/AuthorizationManagerTest.java @@ -0,0 +1,51 @@ +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; +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.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; + + @Test + void shouldDeleteUserAuthorizationByGroupAndRole() throws EntException { + String username = "testUser"; + List groups = Arrays.asList("group1", "group2"); + List roles = Arrays.asList("role1", "role2"); + + authorizationManager.deleteUserAuthorizationByGroupAndRole(username, groups, roles); + + verify(authorizationDAO).deleteUserAuthorizationByGroupAndRole(username, groups, roles); + } + + @Test + void shouldThrowExceptionWhenDaoFailsOnDeleteUserAuthorizationByGroupAndRole() { + String username = "testUser"; + List groups = Arrays.asList("group1"); + List roles = Arrays.asList("role1"); + + when(authorizationDAO.deleteUserAuthorizationByGroupAndRole(anyString(), anyList(), anyList())) + .thenThrow(new RuntimeException("DAO error")); + + assertThrows(EntException.class, () -> + 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 new file mode 100644 index 0000000000..bb9e690098 --- /dev/null +++ b/engine/src/test/java/com/agiletec/aps/system/services/authorization/TestAuthorizationDAO.java @@ -0,0 +1,177 @@ +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.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.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 { + + private AuthorizationDAO authorizationDAO; + private List originalAuthorizations; + private final String USERNAME = "admin"; + + @BeforeEach + void init() throws Exception { + DataSource dataSource = (DataSource) this.getApplicationContext().getBean("servDataSource"); + 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); + + 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(); + 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); + + Group customersGroup = new Group(); + customersGroup.setName("customers"); + Role supervisorRole = new Role(); + supervisorRole.setName("supervisor"); + Authorization auth3 = new Authorization(customersGroup, supervisorRole); + + 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")); + + //Test deleteUserAuthorizationByGroupAndRole with specific groups and roles + authorizationDAO.deleteUserAuthorizationByGroupAndRole( + USERNAME, + Arrays.asList("free", "coach"), + 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 { + authorizationDAO.deleteUserAuthorizations(USERNAME); + + // 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 it 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")); + } + + @Test + void testDeleteUserAuthorizationByGroupAndRoleWithOnlyRoles() { + authorizationDAO.deleteUserAuthorizations(USERNAME); + + // 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 it 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) + && 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 7d10e4cf03..374fd9fd0d 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; @@ -49,7 +50,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 +408,104 @@ 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.externalAuthSyncCheck(username, higherIat)); + // iat minore (1500) -> deve ritornare true (già sincronizzato con un iat superiore) + assertTrue(this.authorizationManager.externalAuthSyncCheck(username, 1500L)); + // iat maggiore (3000) -> deve ritornare false (necessita sincronizzazione) + assertFalse(this.authorizationManager.externalAuthSyncCheck(username, 3000L)); + + } finally { + UserDetails user = this.userManager.getUser(username); + if (null != user) { + this.userManager.removeUser(user); + } + } + } + + @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 new file mode 100644 index 0000000000..8e7c14df53 --- /dev/null +++ b/engine/src/test/java/com/agiletec/aps/system/services/authorization/TestExternalSynchronizationAuthorizationDAO.java @@ -0,0 +1,194 @@ +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 java.time.Instant; +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 authusersextsync")) { + stat.executeUpdate(); + } + } + } + + @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"; + Long iat = 1000L; + // Se l'utente non esiste, deve restituire false + assertFalse(authorizationDAO.externalAuthSyncCheck(username, iat)); + } + + @Test + void testExternalAuthSync_Check_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 authusersextsync (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.externalAuthSyncCheck(username, newIat)); + } + + @Test + 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 authusersextsync (username, iat) VALUES (?, ?)")) { + stat.setString(1, username); + stat.setLong(2, currentIat); + stat.executeUpdate(); + } + } + + // Se l'IAT è uguale, deve restituire true (sincronizzato) + assertTrue(authorizationDAO.externalAuthSyncCheck(username, currentIat)); + } + + @Test + void testExternalAuthSync_Check_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 authusersextsync (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.externalAuthSyncCheck(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 authusersextsync 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 authusersextsync 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/README.md b/keycloak-plugin/README.md index 52676521c9..74943c7d8a 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/aps/servlet/security/KeycloakAuthenticationFilter.java b/keycloak-plugin/src/main/java/org/entando/entando/aps/servlet/security/KeycloakAuthenticationFilter.java index 4b1a7f4c36..a4c8a15ef7 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(); @@ -111,9 +110,7 @@ public Authentication attemptAuthentication(final HttpServletRequest request, fi setUserOnContext(request, user, userAuthentication); - // TODO optimise to not check on every request keycloakGroupManager.processNewUser(user, bearerToken, true); - return userAuthentication; } catch (EntException e) { log.error("System exception", e); 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 ccc7e824fd..131d545a0e 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,7 @@ private void doLogin(final HttpServletRequest request, final HttpServletResponse || tokenResponse.getBody() == null || !tokenResponse.getBody().isActive()) { throw new EntandoTokenException("invalid or expired token", request, "guest"); } + 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 57d277d6ef..cad27ba98b 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 com.agiletec.aps.system.SystemConstants.ADMIN_USER_NAME; import static java.util.Optional.ofNullable; -import static org.entando.entando.keycloak.services.mapping.DynamicMappingKind.CLIENTROLE; import static org.entando.entando.keycloak.services.mapping.DynamicMappingKind.GROUP; -import static org.entando.entando.keycloak.services.mapping.DynamicMappingKind.GROUPROLE; +import static org.entando.entando.keycloak.services.mapping.DynamicMappingKind.ROLEGROUP; import static org.entando.entando.keycloak.services.mapping.DynamicMappingKind.ROLE; +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,35 +16,37 @@ import com.agiletec.aps.system.services.role.Role; import com.agiletec.aps.system.services.role.RoleManager; import com.agiletec.aps.system.services.user.UserDetails; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.dataformat.xml.XmlMapper; import com.google.common.collect.Sets; +import java.time.Instant; 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; +import java.util.Optional; import java.util.Set; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReadWriteLock; import java.util.concurrent.locks.ReentrantReadWriteLock; import java.util.stream.Collectors; -import java.util.stream.StreamSupport; import org.apache.commons.lang3.StringUtils; import org.entando.entando.ent.exception.EntException; import org.entando.entando.ent.util.EntLogging.EntLogFactory; import org.entando.entando.ent.util.EntLogging.EntLogger; import org.entando.entando.keycloak.services.mapping.DynamicMapping; import org.entando.entando.keycloak.services.mapping.DynamicMappingElement; +import org.entando.entando.keycloak.services.mapping.DynamicMappingKind; +import org.entando.entando.keycloak.services.mapping.PersistKind; +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; 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; @@ -55,13 +57,14 @@ public class KeycloakAuthorizationManager extends AbstractService { private static final int GROUP_POSITION = 0; private static final int ROLE_POSITION = 1; - private final ObjectMapper mapper = new ObjectMapper(); private final XmlMapper xmlMapper = new XmlMapper(); private final transient ReadWriteLock configUpdateLock = new ReentrantReadWriteLock(); private final transient Lock readLock = configUpdateLock.readLock(); private final transient Lock writeLock = configUpdateLock.writeLock(); + private int cleanBatchSize; + @Autowired public KeycloakAuthorizationManager(final KeycloakConfiguration configuration, final AuthorizationManager authorizationManager, @@ -82,34 +85,61 @@ 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; + private transient Boolean enabled; + private transient PersistKind persist; @Override public void init() throws Exception { writeLock.lock(); + profileMappings = new ArrayList<>(); jwtMappings = new ArrayList<>(); + try { String xml = configManager.getConfigItem("dynamicAuthMapping"); if (StringUtils.isNotBlank(xml)) { DynamicMapping dynConf = xmlMapper.readValue(xml, DynamicMapping.class); - if (dynConf != null && dynConf.mapping != null) { - - Map> partitioned = - dynConf.mapping.stream() - .filter(this::isValid) - .collect(Collectors.partitioningBy( - item -> item.kind.isJwtMapping() - )); - profileMappings = List.copyOf(partitioned.get(false)); - jwtMappings = List.copyOf(partitioned.get(true)); - log.debug("{} dynamic auth mapping found, {} profileMappings", - dynConf.mapping.size(), profileMappings.size()); + if (dynConf != null) { + if (dynConf.mapping != null) { + + final Map> partitioned = + dynConf.mapping.stream() + .filter(this::isValid) + .filter(m -> m.enabled) + .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 = ofNullable(dynConf.exclusions) + .orElse(List.of()); + roles = ofNullable(dynConf.roles) + .orElseGet(List::of); + groups = ofNullable(dynConf.groups) + .orElse(List.of()); + enabled = ofNullable(dynConf.enabled) + .orElse(false); + persist = ofNullable(dynConf.persist) + .orElse(PersistKind.FULL); } } 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) { + // defaults + enabled = false; + roles = new ArrayList<>(); + groups = new ArrayList<>(); log.error("Error initializing KeycloakAuthorizationManager", e); throw e; } finally { @@ -128,6 +158,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 @@ -138,16 +181,16 @@ private boolean isValid(DynamicMappingElement elem) { log.error("invalid dynamic mapping element, 'kind' is blank"); return false; } - if (StringUtils.isBlank(elem.attribute) && elem.kind != CLIENTROLE) { - log.error("invalid dynamic mapping element, 'attribute' is blank"); + 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.client) && elem.kind == CLIENTROLE) { - log.error("invalid dynamic mapping element, 'client' is blank for CLIENTROLE 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 == ROLEGROUP || elem.kind == ROLEGROUPCLAIM)) { + log.error("invalid dynamic mapping element, 'separator' is blank for {} kind", elem.kind); return false; } return true; @@ -155,64 +198,157 @@ 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 { - // 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); + // Authorizations coming from dynamic mapping (that is, external sources) + final List dynamicAuthorizations = new ArrayList<>(); + final Long iat; + if (StringUtils.isNotBlank(token)) { + iat = OidcMappingHelper.extractIssuedAtFromJwt(token, decode, user.getUsername()); + } else { + iat = 0L; + } + + if (iat == null) { + log.debug("Could not extract IAT from JWT, skipping user '{}' synchronization", user.getUsername()); + return; + } + + // abort if already synced + if (iat > 0 && authorizationManager.externalAuthSyncCheck(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) { + 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)); } - } finally { + syncAuthorizations(user, dynamicAuthorizations, iat); + } catch (EntException e) { + throw new RuntimeException(e); + } finally { readLock.unlock(); } } + /** + * @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 + .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, groupName, roleName); + }) + .collect(Collectors.toList()); + // list of the _managed_ authorizations currently assigned to the user + List existingAuths = ofNullable(user.getAuthorizations()) + .orElse(List.of()) + .stream() + .filter(a -> (a.getGroup() != null && groups.contains(a.getGroup().getName()) + || (a.getRole() != null && roles.contains(a.getRole().getName()))) + ) + .collect(Collectors.toList()); + // If the existing authorization is not included in the dynamic authorizations, it must be removed + List toDelete = existingAuths + .stream() + .filter(a -> { + return dynamicAuthorizations.stream() + .noneMatch(d -> d.equals(a)); + }) + .collect(Collectors.toList()); + + // update authorizations + if (persist == PersistKind.FULL) { + this.authorizationManager.externalAuthSync(user.getUsername(), iat, toAdd, toDelete); + } + user.getAuthorizations().removeAll(toDelete); + user.addAuthorizations(toAdd); + } + /** * Analyze the JWT looking for known mappings to translate into Entando roles * @param user logged in user * @param token access token * @param decode is true the access token is decoded from the base64 form - * @param tokenMapper the mapping configuration + * @param claimMapper the mapping configuration + * @return the list of authorizations extracted from the JWT */ - private void processRoleClaimAttributes(UserDetails user, String token, boolean decode, DynamicMappingElement tokenMapper) { - final String payload = decode ? token.split("\\.")[1] : token; - final String json = decode ? new String(Base64.getUrlDecoder().decode(payload)) : payload; + private List processJwtClaimAttributes(final UserDetails user, final String token, final boolean decode, final DynamicMappingElement claimMapper) { + final List authorizations = OidcMappingHelper.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) { + jwtAuthorizations.addAll(finalizeRoleAssociation(kcUser, claimMapper, authorizations)); + } else if (claimMapper.kind == DynamicMappingKind.GROUPCLAIM) { + jwtAuthorizations.addAll(finalizeGroupAssociation(kcUser, claimMapper, authorizations)); + } else { + jwtAuthorizations.addAll(finalizeGroupRoleAssociation(kcUser, claimMapper, authorizations)); + } + } + return jwtAuthorizations; + } - try { - final JsonNode root = mapper.readTree(json); + private List finalizeGroupRoleAssociation(KeycloakUser user, DynamicMappingElement elem, List authorizations) { + List result = new ArrayList<>(); + if (authorizations == null) return result; + + for (String candidate : authorizations) { + try { + if (StringUtils.isBlank(candidate)) { + continue; + } - JsonNode roleNode = root - .path("resource_access") - .path(tokenMapper.client) - .path("roles"); + final String sep = StringUtils.isNotBlank(elem.separator) ? elem.separator : DEFAULT_SEPARATOR; + final String[] tokens = candidate.split(sep); - if (roleNode == null) { - return ; - } + if (tokens.length < 2) { + // treat as a role + result.addAll(finalizeRoleAssociation(user, elem, List.of(candidate.trim()))); + continue; + } - List roles = StreamSupport.stream(roleNode.spliterator(), false) - .map(JsonNode::asText) - .collect(Collectors.toList()); - if (user instanceof KeycloakUser) { - finalizeRoleAssociation((KeycloakUser) user, tokenMapper, roles); - } + final String roleName = tokens[0].trim(); + final String groupName = tokens[1].trim(); - } catch (Exception e) { - log.error("error importing client role into Entando roles", e); + if (StringUtils.isBlank(roleName)) { + log.warn("Invalid role name extracted from candidate '{}' for user {}", candidate, user.getUsername()); + continue; + } + + 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) { @@ -248,286 +384,256 @@ 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 (Exception 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) { - 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); - } + private Role findOrCreateRole(final String roleName) { + Role newRole = roleManager.getRole(roleName); + + if (newRole != null) { + return newRole; + } + + newRole = new Role(); + newRole.setName(roleName); + newRole.setDescription(roleName); + try { + 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); } - return role; } /** * 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 synchronized 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 == GROUPROLE) { - doProcessGroupRole(user, m); + if (m.kind == ROLEGROUP) { + result.addAll(doProcessRoleGroup(user, m)); } }); + return result; } - private void doProcessGroupRole(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; try { - final List authorizations = processUserProfileAttribute(user, elem); + final List authorizations = OidcMappingHelper.extractAuthorizationsFromProfile(user, elem); if (authorizations == null) { - return; + return result; } for (String groupRoleToken : authorizations) { - parseAuthForGroupRole(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 parseAuthForGroupRole(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 || StringUtils.isBlank(tokens[0]) || StringUtils.isBlank(tokens[1])) { log.error("invalid dynamic config configuration detected"); - return; + return null; } - final String groupName = tokens[0]; - final String roleName = tokens[1]; - - Authorization authorization; - Group group = null; - Role role = null; + final String groupName = tokens[1]; + final String roleName = tokens[0]; - if (elem.persist) { - if (StringUtils.isNotBlank(groupName)) { - group = findOrCreateGroup(groupName); - } + return finalizeAssociation(user, roleName, groupName, false); + } - if (StringUtils.isNotBlank(roleName)) { - role = findOrCreateRole(roleName); - } - authorization = new Authorization(group, role); - - persistAuthIfMissing(user, authorization); - } else { - if (StringUtils.isNotBlank(groupName)) { - group = new Group(); - group.setName(groupName); - group.setDescription("sys:" + groupName); - } - if (StringUtils.isNotBlank(roleName)) { - // make sure all the permissions are assigned to the current role - role = roleManager.getRole(roleName); - } - authorization = new Authorization(group, role); - } - user.addAuthorization(authorization); + private 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 * @param elem a single dynamic configuration + * @return the list of authorizations extracted from the user profile */ - private void doProcessRole(KeycloakUser user, DynamicMappingElement elem) { - final List authorizations = processUserProfileAttribute(user, elem); - finalizeRoleAssociation(user, elem, authorizations); + private List doProcessRole(KeycloakUser user, DynamicMappingElement elem) { + final List authorizations = OidcMappingHelper.extractAuthorizationsFromProfile(user, elem); + return finalizeRoleAssociation(user, elem, authorizations); } - private void finalizeRoleAssociation(KeycloakUser user, DynamicMappingElement elem, List authorizations) { - if (authorizations == null) { - return; - } - for (String kca: authorizations) { + private List finalizeRoleAssociation(KeycloakUser user, DynamicMappingElement elem, List authorizations) { + List result = new ArrayList<>(); + if (authorizations == null) return result; + + for (String roleName : authorizations) { try { - // skip if the group is already mapped - if (user.getAuthorizations() - .stream() - .anyMatch(a -> a.getRole() != null - && a.getRole().getName().equals(kca))) { - log.debug("Role {} already assigned to user {}", kca, user.getUsername()); - return; + Authorization auth = finalizeAssociation(user, elem, roleName, null); + if (auth != null) { + result.add(auth); } - final Authorization auth = elem.persist - ? createPersistedRoleAuthorization(user, kca) - : createTransientRoleAuthorization(kca); - - user.addAuthorization(auth); - log.info("Successfully assigned role {} to user {}", kca, user.getUsername()); } catch (Exception e) { - log.error("Error processing dynamic role '{}' for user {}", kca , user.getUsername(), e); + log.error("Error processing dynamic role '{}' for user {}", roleName, user.getUsername(), e); } } + return result; } - 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 finalizeAssociation(KeycloakUser user, DynamicMappingElement elem, String roleName, String groupName) { + return finalizeAssociation(user, roleName, groupName, true); } - private Authorization createTransientRoleAuthorization(String roleName) { - Role role = roleManager.getRole(roleName); - if (role == null) { - role = new Role(); - role.setName(roleName); + private boolean isIgnored(String name) { + if (ignore == null || StringUtils.isBlank(name)) { + return false; } - return new Authorization(null, role); + return ignore.contains(name.trim()); } - /** - * Process the dynamic Group authorization for the given user - * @param user the currently logging-in user - * @param elem a single dynamic configuration - */ - private void doProcessGroup(KeycloakUser user, DynamicMappingElement elem) { - final List authorizations = processUserProfileAttribute(user, elem); - if (authorizations == null) { - return; + private Authorization finalizeAssociation(KeycloakUser user, String roleName, String groupName, + boolean createRoleIfMissing) { + // 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; } - for (String kca: authorizations) { - try { - // skip if the role is already mapped - if (user.getAuthorizations() - .stream() - .anyMatch(a -> a.getGroup() != null - && a.getGroup().getName().equals(kca))) { - log.debug("Group {} already assigned to user {}", kca, user.getUsername()); - return; - } - final Authorization auth = elem.persist - ? createPersistedGroupAuthorization(user, kca) - : createTransientGroupAuthorization(kca); + // 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)) { + log.info("Group {} is not managed. Skipping assignment for user {}", groupName, user.getUsername()); + return null; + } + return createAuthorization(roleName, groupName, createRoleIfMissing); + } - user.addAuthorization(auth); - log.info("Successfully assigned group {} to user {}", kca, user.getUsername()); - } catch (Exception e) { - log.error("Error processing dynamic group for user {}", user.getUsername(), e); - } + private Authorization createAuthorization(String roleName, String groupName, boolean createRoleIfMissing) { + if (shouldPersistAuthorization()) { + return createPersistedAuthorization(roleName, groupName); } + return createTransientAuthorization(roleName, groupName, createRoleIfMissing); } - 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 boolean shouldPersistAuthorization() { + return this.persist == PersistKind.AUTH || this.persist == PersistKind.FULL; } - private Authorization createTransientGroupAuthorization(String groupName) { - Group group = new Group(); - group.setName(groupName); - return new Authorization(group, null); + 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); } - /** - * Process dynamic configuration element for a user. If the attribute is missing, it skips processing. - * @param user the Keycloak user - * @param elem the dynamic mapping element - * @return the list of processed attribute tokens or null if the attribute is missing - */ - private static List processUserProfileAttribute(KeycloakUser user, DynamicMappingElement elem) { - if (user.getUserRepresentation() == null - || user.getUserRepresentation().getAttributes() == null - || !user.getUserRepresentation().getAttributes().containsKey(elem.attribute)) { - log.info("skipping dynamic processing for user {}", user.getUsername()); - return Collections.emptyList(); + 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; } - final Object kcProfileAttr = user.getUserRepresentation() - .getAttributes() - .get(elem.attribute); - return handleKeycloakAttribute(kcProfileAttr); + return createRoleIfMissing ? createTransientRole(roleName) : roleManager.getRole(roleName); } - /** - * To avoid creating duplicate records, we are forced to check if the authorization already exists. - * @param user the user being processed - * @param auth the authorization to persist - * @throws EntException in case of errors - */ - private synchronized void persistAuthIfMissing(KeycloakUser user, Authorization auth) throws EntException { - final List existing = authorizationManager.getUserAuthorizations(user.getUsername()); - if (existing.stream() - .noneMatch(a -> (a.getGroup() != null && a.getRole() == null - && auth.getGroup() != null - && a.getGroup().getName().equals(auth.getGroup().getName())) - || - (a.getRole() != null && a.getGroup() == null - && auth.getRole() != null - && a.getRole().getName().equals(auth.getRole().getName())) - - || - (a.getRole() != null && a.getGroup() != null - && auth.getRole() != null - && auth.getGroup() != null - && a.getRole().getName().equals(auth.getRole().getName()) - && a.getGroup().getName().equals(auth.getGroup().getName())) - ) - ) { - log.debug("dynamically persisting authorization for user '{}' : group {}, role {}", user.getUsername(), - auth.getGroup() != null ? auth.getGroup().getName() : "N/A", - auth.getRole() != null ? auth.getRole().getName() : "N/A"); - authorizationManager.addUserAuthorization(user.getUsername(), auth); + 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; + final String existingGroupName = (a.getGroup() != null) ? a.getGroup().getName() : null; + + return Objects.equals(existingRoleName, roleName) + && Objects.equals(existingGroupName, groupName); + }); + } + + private @NonNull Role createTransientRole(String roleName) { + Role role = roleManager.getRole(roleName); + if (role == null) { + role = new Role(); + role.setName(roleName); + role.setDescription(roleName); } + return role; } /** - * 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 + * 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 */ - 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()); + private List doProcessGroup(KeycloakUser user, DynamicMappingElement elem) { + final List authorizations = OidcMappingHelper.extractAuthorizationsFromProfile(user, elem); + if (authorizations == null) { + return new ArrayList<>(); + } + return finalizeGroupAssociation(user, elem, authorizations); + } + + private List finalizeGroupAssociation(KeycloakUser user, DynamicMappingElement elem, List authorizations) { + List result = new ArrayList<>(); + if (authorizations == null) return result; + + for (String groupName : authorizations) { + try { + 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 new ArrayList<>(); + return result; } + public void setCleanBatchSize(Integer cleanBatchSize) { + this.cleanBatchSize = cleanBatchSize; + } } 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 b3b30ecdd5..8326749048 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,11 +5,22 @@ 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(localName = "exclusions") + public List exclusions; + + @JacksonXmlElementWrapper(localName = "roles") + public List roles; + + @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 bddf07f618..e511dd0096 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,7 @@ public class DynamicMappingElement { public boolean enabled; public String attribute; public DynamicMappingKind kind; - public String injectTo; - public boolean persist; - public String separator; // FOR GROUPROLE ONLY - public String client; // FOR CLIENTROLE ONLY + public String separator; // FOR ROLEGROUP and GROUPROLECLAIM 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/DynamicMappingKind.java b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/DynamicMappingKind.java index 3cce689f39..d682702b06 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,8 +9,10 @@ public enum DynamicMappingKind { GROUP("group", false), ROLE("role", false), - GROUPROLE("grouprole", false), - CLIENTROLE("clientrole", true); + ROLEGROUP("rolegroup", false), + ROLECLAIM("roleclaim", true), + GROUPCLAIM("groupclaim", 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 new file mode 100644 index 0000000000..71026a97e1 --- /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 PersistKind: " + value)); + } +} 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 new file mode 100644 index 0000000000..e0db58e5d4 --- /dev/null +++ b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/oidc/OidcMappingHelper.java @@ -0,0 +1,164 @@ +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; + +public class OidcMappingHelper { + + private OidcMappingHelper() {} + + private static final EntLogger log = EntLogFactory.getSanitizedLogger(OidcMappingHelper.class); + + private static final ObjectMapper mapper = new ObjectMapper(); + + 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); + + if (isNodeMissing(authNode)) { + log.debug("Path '{}' not found in JWT claims for user {}", claimMapper.path, username); + return Collections.emptyList(); + } + + return extractAuthorizationsFromNode(authNode, claimMapper); + + } 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 static Long extractIssuedAtFromJwt(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 static 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 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 static boolean isNodeMissing(JsonNode node) { + return node == null || node.isMissingNode() || node.isNull(); + } + + private static List extractAuthorizationsFromNode(JsonNode authNode, DynamicMappingElement claimMapper) { + if (authNode.isArray()) { + return extractFromArrayNode(authNode); + } + 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(); + } + + private static 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 + * @param elem the dynamic mapping element + * @return the list of processed attribute tokens or null if the attribute is missing + */ + public static 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); + } + + /** + * Process user attribute of the Keycloak profile. If it's a list, it will be flattened and split by whitespace. + * If it's a string, it will be split by whitespace. + * @param attribute the attribute data + * @return the list of the processed attribute tokens + */ + protected static List handleKeycloakAttribute(Object attribute) { + if (attribute instanceof List) { + List 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/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 f631f4c253..50e9746f6a 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,28 +1,52 @@ - - - false - sim730 - CLIENTROLE - false - - - false - AD_ROLE - ROLE - false - - - false - AD_GROUP - GROUP - false - - - false - AD_GROUPROLE - GROUPROLE - _r_ - false - - + + 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/main/resources/spring/plugins/keycloak/aps/keycloak.xml b/keycloak-plugin/src/main/resources/spring/plugins/keycloak/aps/keycloak.xml index dc9f3a1a57..5dbca27fdc 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,15 +4,23 @@ 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"> + + cron="${KC_CONFIG_REFRESH:0 * * * * *}" /> + + + + + class="org.entando.entando.keycloak.services.KeycloakConfiguration"> @@ -38,7 +47,7 @@ + class="org.entando.entando.keycloak.adapter.AuthenticationProviderManagerAdapter"> @@ -49,7 +58,7 @@ + class="org.entando.entando.keycloak.adapter.UserManagerAdapter"> @@ -72,7 +81,7 @@ + class="org.entando.entando.keycloak.adapter.EntandoOauth2InterceptorAdapter"> 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 b20dcf1a48..2882949464 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; @@ -146,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); @@ -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 new file mode 100644 index 0000000000..a7da3622e7 --- /dev/null +++ b/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerComplexTest.java @@ -0,0 +1,396 @@ +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.anyLong; +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.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.ent.exception.EntException; +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.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 KeycloakAuthorizationManager manager; + + @BeforeEach + public void setUp() { + manager = new KeycloakAuthorizationManager(configuration, authorizationManager, groupManager, roleManager, configManager); + lenient().when(configuration.getDefaultAuthorizations()).thenReturn(""); + try { + lenient().when(authorizationManager.externalAuthSyncCheck(anyString(), any(Long.class))).thenReturn(false); + } catch (EntException e) { + // ignore + } + } + + private String createToken(String jsonPayload) { + return "header." + java.util.Base64.getUrlEncoder().encodeToString(jsonPayload.getBytes()) + ".signature"; + } + + 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); + + verify(authorizationManager, times(1)).externalAuthSync(eq("test-user"), anyLong(), anyList(), anyList()); + } + + @Test + void testJwtClaimMapping() throws Exception { + String xml = "" + + " true" + + " none" + + " " + + " " + + " true" + + " resource_access.client1.roles" + + " roleclaim" + + " " + + " " + + " " + + " jwt-role1" + + " " + + ""; + setMappingConfig(xml); + + String token = createToken("{\"iat\":123, \"resource_access\":{\"client1\":{\"roles\":[\"jwt-role1\"]}}}"); + 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 = createToken("{\"iat\":123, \"custom_groups\":[\"jwt-group1\", \"jwt-group2\"]}"); + 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 = createToken("{\"iat\":123, \"complex_auth\":[\"roleA:groupA\", \"roleB:groupB\"]}"); + 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)).externalAuthSync(eq("test-user"), anyLong(), 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; + } + +} 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 c9ccce4b16..3dcd425ba8 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,11 +1,16 @@ 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; 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; 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; @@ -46,16 +51,19 @@ class KeycloakAuthorizationManagerTest { private KeycloakAuthorizationManager manager; @BeforeEach - public void setUp() { + public void setUp() throws EntException { manager = new KeycloakAuthorizationManager(configuration, authorizationManager, groupManager, roleManager, configManager); + lenient().when(authorizationManager.externalAuthSyncCheck(anyString(), any(Long.class))).thenReturn(false); } @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); @@ -79,12 +87,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); @@ -111,7 +121,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"); @@ -122,7 +149,13 @@ void testVerification() { 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()); } @Test @@ -140,14 +173,9 @@ void testDynamicConfigurationRoleOnLogin() throws Exception { manager.init(); - final ArgumentCaptor authCaptor = ArgumentCaptor.forClass(Authorization.class); - 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 @@ -156,24 +184,82 @@ 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(); - final ArgumentCaptor authCaptor = ArgumentCaptor.forClass(Authorization.class); + manager.processNewUser(userDetails, JWT, false); + + verify(authorizationManager, times(1)).externalAuthSync(eq("testuser"), anyLong(), anyList(), anyList()); + } + + @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(); manager.processNewUser(userDetails, JWT, false); - verify(authorizationManager, times(1)).addUserAuthorization(eq("testuser"), authCaptor.capture()); + verify(authorizationManager, never()).addUserAuthorization(eq("testuser"), any()); - assertThat(authCaptor.getValue().getRole().getName()).isEqualTo("generico"); - assertThat(authCaptor.getValue().getGroup()).isNull(); + @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(captured.get(0).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(); + + manager.processNewUser(userDetails, JWT, false); + + verify(authorizationManager, times(1)).externalAuthSync(eq("testuser"), anyLong(), anyList(), anyList()); + } + + @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(); + + manager.processNewUser(userDetails, JWT, false); + + verify(authorizationManager, never()).addUserAuthorization(eq("testuser"), any()); + + @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"); + captured.forEach(ac -> assertThat(ac.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(); @@ -197,14 +283,9 @@ void testDynamicConfigurationGroupOnLogin() throws Exception { manager.init(); - final ArgumentCaptor authCaptor = ArgumentCaptor.forClass(Authorization.class); - 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 @@ -215,19 +296,22 @@ 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); - final ArgumentCaptor authCaptor = ArgumentCaptor.forClass(Authorization.class); + when(userDetails.getUserRepresentation()).thenReturn(userRepresentation); 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 @@ -236,21 +320,16 @@ 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); manager.init(); - final ArgumentCaptor authCaptor = ArgumentCaptor.forClass(Authorization.class); - 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 @@ -259,22 +338,25 @@ 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); 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 @@ -288,23 +370,35 @@ 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); + when(userDetails.getUsername()).thenReturn("testuser"); 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); + 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 @@ -313,7 +407,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); @@ -330,7 +424,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); @@ -376,6 +470,596 @@ 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(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("arole_r_agroup"))); + + when(userDetails.getUsername()).thenReturn("testuser"); + when(userDetails.getUserRepresentation()).thenReturn(userRepresentation); + + // Simulate a conflict exception + lenient().doThrow(new EntException("Conflict")) + .when(authorizationManager).addUserAuthorization(eq("testuser"), any()); + + manager.init(); + + // This should not throw an exception because it's caught in syncAuthorizations + manager.processNewUser(userDetails, null, true); + + verify(authorizationManager, times(1)).externalAuthSync(eq("testuser"), anyLong(), anyList(), anyList()); + } + + @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(); + + manager.processNewUser(userDetails, JWT_ROLEGROUP, false); + + verify(authorizationManager, times(1)).externalAuthSync(eq("testuser"), anyLong(), anyList(), anyList()); + } + + @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(); + + manager.processNewUser(userDetails, JWT_ROLEGROUP_EDGE, false); + + verify(authorizationManager, times(1)).externalAuthSync(eq("testuser"), anyLong(), anyList(), anyList()); + } + + @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(); + + manager.processNewUser(userDetails, JWT_ROLEGROUP, false); + + verify(authorizationManager, never()).addUserAuthorization(eq("testuser"), any()); + + @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) + .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 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); + when(userDetails.getUsername()).thenReturn("testuser"); + when(configManager.getConfigItem(anyString())).thenReturn(XML_WITH_IGNORE); + + manager.init(); + + manager.processNewUser(userDetails, JWT, false); + + verify(authorizationManager, times(1)).externalAuthSync(eq("testuser"), anyLong(), anyList(), anyList()); + } + + @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); + + verify(authorizationManager, times(1)).externalAuthSync(eq("testuser"), anyLong(), anyList(), anyList()); + } + + @Test + void testRoleFromProfileAndJwtWithPersistAuth() throws Exception { + // Configurazione: un mapping per profilo (ROLE) e uno per JWT (ROLECLAIM), entrambi con persist=AUTH + String xmlConf = "" + + " AUTH" + + " true" + + "" + + " " + + " true" + + " AD_ROLE" + + " ROLE" + + " " + + " " + + " true" + + " realm_access.roles" + + " ROLECLAIM" + + " " + + "" + + + " " + + " 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); + + // 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 + @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()).addAuthorizations(listCaptor.capture()); + + 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")); + } + + @Test + void testAuthAssignmentWhenRoleGroupExistWithPersistAuth() throws Exception { + String xmlConf = "" + + " AUTH" + + " true" + + "" + + " " + + " true" + + " AD_ROLE" + + " ROLE" + + " " + + "" + + " " + + " existing_role" + + " " + + ""; + + 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.getAuthorizations()).thenReturn(new ArrayList<>()); + + manager.init(); + manager.processNewUser(userDetails, null, false); + + // Verifica che l'autorizzazione sia stata aggiunta all'utente + @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"); + + // Verifica che non sia stata chiamata la persistenza + verify(authorizationManager, never()).addUserAuthorization(anyString(), any()); + } + + @Test + void testAuthAssignmentWhenRoleExistsAndAddRoleFailsWithPersistAuth() throws Exception { + String xmlConf = "" + + " AUTH" + + " true" + + "" + + " " + + " true" + + " AD_ROLE" + + " ROLE" + + " " + + "" + + " " + + " conflict_role" + + " " + + ""; + + when(configuration.getDefaultAuthorizations()).thenReturn(null); + when(configManager.getConfigItem(anyString())).thenReturn(xmlConf); + + Role existingRole = new Role(); + existingRole.setName("conflict_role"); + + // 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); + + // 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.getAuthorizations()).thenReturn(new ArrayList<>()); + + manager.init(); + manager.processNewUser(userDetails, null, false); + + // Verifichiamo che l'autorizzazione sia stata comunque aggiunta all'utente + @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"); + } + + + + @Test + void testCleanupManagedAuthorizationsWithRolesAndGroups() throws Exception { + String xml = "" + + " true" + + " full" + + " " + + " roleA" + + " roleB" + + " " + + " " + + " groupA" + + " groupB" + + " " + + ""; + + when(configManager.getConfigItem("dynamicAuthMapping")).thenReturn(xml); + when(userDetails.getUsername()).thenReturn("john"); + + List existingAuths = new ArrayList<>(); + existingAuths.add(authorization("groupA", "roleA")); + when(userDetails.getAuthorizations()).thenReturn(existingAuths); + + manager.init(); + manager.processNewUser(userDetails, null, false); + + // It should try to sync using externalAuthSync + 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.externalAuthSyncCheck(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.externalAuthSyncCheck(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); @@ -384,246 +1068,655 @@ private Authorization authorization(final String groupName, final String roleNam return new Authorization(group, role); } - private static final String XML_ROLE_CONF = "" + private Authorization roleOnlyAuthorization(final String roleName) { + final Role role = new Role(); + role.setName(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 = + "" + + "" + + "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" + + "" + " " - + " true" + + " false" + " AD_ROLE" + " ROLE" - + " true" + " " + " " - + " false" + + " true" + " AD_GROUP" + " GROUP" - + " true" + " " + " " + " false" + " AD_GROUPROLE" - + " GROUPROLE" + + " ROLEGROUP" + " _r_" - + " true" + " " - + ""; - - private static final String XML_GROUP_CONF = "" + + "" + +"" + + " 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" - + " true" + " " + " " - + " true" + + " false" + " AD_GROUP" + " GROUP" - + " true" + " " + " " - + " false" + + " true" + " AD_GROUPROLE" - + " GROUPROLE" + + " ROLEGROUP" + " _r_" - + " true" + " " - + ""; - - private static final String XML_GROUP_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" - + " true" + " " + " " - + " true" + + " false" + " AD_GROUP" + " GROUP" - + " false" + " " + " " - + " false" + + " true" + " AD_GROUPROLE" - + " GROUPROLE" + + " ROLEGROUP" + " _r_" - + " true" + " " - + ""; - - private static final String XML_GROUP_ROLE_CONF = "" + + "" + + "" + + " default-roles-entando-development" + + " offline_access" + + " uma_authorization" + + " " + + " " + + " imported_role" + + " arole" + + " " + + " " + + " imported_group" + + " agroup" + + " " + + ""; + + private static final String XML_WITH_IGNORE_GROUP = "" + + "FULL" + + " true" + + "" + " " - + " false" - + " AD_ROLE" - + " ROLE" - + " true" + + " true" + + " groups" + + " GROUPCLAIM" + " " + + "" + + " altro-gruppo" + + "" + + " imported_role" + + " imported_role2" + + " " + + " " + + " Gruppo-Microsoft-Importato" + + " imported_group2" + + " " + + ""; + + private static final String XML_WITH_IGNORE = "" + + " FULL" + + " true" + + "" + " " - + " false" - + " AD_GROUP" - + " GROUP" - + " true" + + " true" + + " realm_access.roles" + + " ROLECLAIM" + " " + + "" + + "" + + " offline_access" + + " uma_authorization" + + " default-roles-entando" + + "" + + "" + + " generico" + + " imported_role2" + + " " + + " " + + " imported_group" + + " imported_group2" + + " " + + ""; + + private static final String XML_ROLE_CLAIM = "" + + " FULL" + + " true" + + "" + " " + " true" - + " AD_GROUPROLE" - + " GROUPROLE" - + " _r_" - + " true" + + " realm_access.roles" + + " ROLECLAIM" + " " - + ""; - - private static final String XML_GROUP_ROLE_CONF_NO_PERSIST = "" + + "" + + "" + + " 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" + + "" + " " - + " false" - + " AD_ROLE" - + " ROLE" - + " 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 XML_GROUP_CLAIM = "" + + "" + + " FULL" + + " true" + + "" + " " - + " false" - + " AD_GROUP" - + " GROUP" - + " true" + + " true" + + " groups" + + " GROUPCLAIM" + " " + + "" + + "" + + " 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" - + " AD_GROUPROLE" - + " GROUPROLE" - + " _r_" - + " false" + + " groups" + + " GROUPCLAIM" + " " - + ""; - - private static final String XML_CLIENT_ROLE = "" + + "" + + "" + + " 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" - + " sim730" - + " CLIENTROLE" - + " true" + + " realm_access.roles" + + " ROLEGROUPCLAIM" + + " _SEP_" + " " - + ""; - - private static final String XML_NO_MAPPING = "" - + ""; + + "" + + "" + + " 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" + + " _SEP_" + + " " + + "" + + "" + + " 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 - + " true" + " " + " " + " true" + " AD_GROUP" + " GROUP" // unknown - + " true" + " " + + " " + " false" // + " AD_GROUPROLE" // attribute null - + " GROUPROLE" + + " ROLEGROUP" + " _r_" - + " true" + " " + " " + " false" + " AD_ROLE" - + " CLIENTROLE" // no client - + " true" + + " ROLECLAIM" // no path + " " + " " + " false" + " AD_GROUPROLE" - + " GROUPROLE" + + " ROLEGROUP" // + " _r_" // separator null - + " true" + " " - + ""; + + "" + + "" + + " 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" - + " \"alg\" : \"RS256\",\n" - + " \"typ\" : \"JWT\",\n" - + " \"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" - + " \"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" - + " \"miei_ruoli_custom\" : [ \"offline_access\", \"uma_authorization\", \"default-roles-entando\" ]\n" - + " },\n" - + " \"signature\" : \"dLENSPEPw\"\n" + 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\"," + + " \"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 = "{\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" - + " \"realm_access\" : {\n" - + " \"roles\" : [ \"offline_access\", \"uma_authorization\", \"default-roles-entando\" ]\n" - + " },\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" - + " \"miei_ruoli_custom\" : [ \"offline_access\", \"uma_authorization\", \"default-roles-entando\" ]\n" + 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/oidc/OidcMappingHelperTest.java b/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/oidc/OidcMappingHelperTest.java new file mode 100644 index 0000000000..cafee84a8a --- /dev/null +++ b/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/oidc/OidcMappingHelperTest.java @@ -0,0 +1,71 @@ +package org.entando.entando.keycloak.services.oidc; + +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.Test; + +class OidcMappingHelperTest { + + @Test + void shouldReturnEmptyListWhenJwtFormatIsInvalid() { + String invalidToken = "part1.part2"; // Only 2 parts instead of 3 + DynamicMappingElement claimMapper = new DynamicMappingElement(); + claimMapper.path = "roles"; + + List result = OidcMappingHelper.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 = OidcMappingHelper.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 = OidcMappingHelper.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 = OidcMappingHelper.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 = OidcMappingHelper.extractAuthorizationsFromJwt(token, true, claimMapper, "testUser"); + + Assertions.assertEquals(2, result.size()); + Assertions.assertTrue(result.contains("admin")); + Assertions.assertTrue(result.contains("user")); + } +} diff --git a/pom.xml b/pom.xml index f7fdeb2dd9..406ca3c02c 100644 --- a/pom.xml +++ b/pom.xml @@ -130,7 +130,7 @@ 1.3 3.11.2 5.7.2 - 1.17.6 + 1.21.4 4.0.1 1.3.2 1.1.3 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 53865e16ae..f52d39a6ea 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 7509fd4e0c..2bf930641b 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 diff --git a/webapp/pom.xml b/webapp/pom.xml index a80615bd2a..84c95c2b37 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