diff --git a/connections/jpa-liquibase/pom.xml b/connections/jpa-liquibase/pom.xml index d877dea28b..d541d2a6cb 100755 --- a/connections/jpa-liquibase/pom.xml +++ b/connections/jpa-liquibase/pom.xml @@ -19,6 +19,11 @@ keycloak-connections-jpa ${project.version} + + org.keycloak + keycloak-services + ${project.version} + org.liquibase liquibase-core @@ -33,6 +38,7 @@ org.jboss.logging jboss-logging + diff --git a/connections/jpa-liquibase/src/main/java/org/keycloak/connections/jpa/updater/liquibase/LiquibaseJpaUpdaterProvider.java b/connections/jpa-liquibase/src/main/java/org/keycloak/connections/jpa/updater/liquibase/LiquibaseJpaUpdaterProvider.java index 04a186c533..a611c3cd2c 100644 --- a/connections/jpa-liquibase/src/main/java/org/keycloak/connections/jpa/updater/liquibase/LiquibaseJpaUpdaterProvider.java +++ b/connections/jpa-liquibase/src/main/java/org/keycloak/connections/jpa/updater/liquibase/LiquibaseJpaUpdaterProvider.java @@ -28,6 +28,7 @@ import liquibase.resource.ClassLoaderResourceAccessor; import liquibase.servicelocator.ServiceLocator; import org.jboss.logging.Logger; import org.keycloak.connections.jpa.updater.JpaUpdaterProvider; +import org.keycloak.models.KeycloakSession; import java.sql.Connection; import java.sql.ResultSet; @@ -50,9 +51,12 @@ public class LiquibaseJpaUpdaterProvider implements JpaUpdaterProvider { } @Override - public void update(Connection connection) { + public void update(KeycloakSession session, Connection connection) { logger.debug("Starting database update"); + // Need ThreadLocal as liquibase doesn't seem to have API to inject custom objects into tasks + ThreadLocalSessionContext.setCurrentSession(session); + try { Liquibase liquibase = getLiquibase(connection); @@ -81,7 +85,10 @@ public class LiquibaseJpaUpdaterProvider implements JpaUpdaterProvider { } } catch (Exception e) { throw new RuntimeException("Failed to update database", e); + } finally { + ThreadLocalSessionContext.removeCurrentSession(); } + logger.debug("Completed database update"); } diff --git a/connections/jpa-liquibase/src/main/java/org/keycloak/connections/jpa/updater/liquibase/ThreadLocalSessionContext.java b/connections/jpa-liquibase/src/main/java/org/keycloak/connections/jpa/updater/liquibase/ThreadLocalSessionContext.java new file mode 100644 index 0000000000..24a583f3c1 --- /dev/null +++ b/connections/jpa-liquibase/src/main/java/org/keycloak/connections/jpa/updater/liquibase/ThreadLocalSessionContext.java @@ -0,0 +1,23 @@ +package org.keycloak.connections.jpa.updater.liquibase; + +import org.keycloak.models.KeycloakSession; + +/** + * @author Marek Posolda + */ +public class ThreadLocalSessionContext { + + private static final ThreadLocal currentSession = new ThreadLocal(); + + public static KeycloakSession getCurrentSession() { + return currentSession.get(); + } + + public static void setCurrentSession(KeycloakSession session) { + currentSession.set(session); + } + + public static void removeCurrentSession() { + currentSession.remove(); + } +} diff --git a/connections/jpa-liquibase/src/main/java/org/keycloak/connections/jpa/updater/liquibase/custom/CustomKeycloakTask.java b/connections/jpa-liquibase/src/main/java/org/keycloak/connections/jpa/updater/liquibase/custom/CustomKeycloakTask.java new file mode 100644 index 0000000000..40d71cb7b8 --- /dev/null +++ b/connections/jpa-liquibase/src/main/java/org/keycloak/connections/jpa/updater/liquibase/custom/CustomKeycloakTask.java @@ -0,0 +1,97 @@ +package org.keycloak.connections.jpa.updater.liquibase.custom; + +import java.sql.Connection; +import java.sql.ResultSet; +import java.util.ArrayList; +import java.util.List; + +import liquibase.change.custom.CustomSqlChange; +import liquibase.database.Database; +import liquibase.database.jvm.JdbcConnection; +import liquibase.exception.CustomChangeException; +import liquibase.exception.SetupException; +import liquibase.exception.ValidationErrors; +import liquibase.resource.ResourceAccessor; +import liquibase.snapshot.SnapshotGeneratorFactory; +import liquibase.statement.SqlStatement; +import liquibase.structure.core.Table; +import org.keycloak.connections.jpa.updater.liquibase.ThreadLocalSessionContext; +import org.keycloak.models.KeycloakSession; + +/** + * @author Marek Posolda + */ +public abstract class CustomKeycloakTask implements CustomSqlChange { + + protected KeycloakSession kcSession; + + protected Database database; + protected JdbcConnection jdbcConnection; + protected Connection connection; + protected StringBuilder confirmationMessage = new StringBuilder(); + protected List statements = new ArrayList(); + + @Override + public ValidationErrors validate(Database database) { + return null; + } + + @Override + public void setFileOpener(ResourceAccessor resourceAccessor) { + + } + + @Override + public String getConfirmationMessage() { + return confirmationMessage.toString(); + } + + @Override + public void setUp() throws SetupException { + this.kcSession = ThreadLocalSessionContext.getCurrentSession(); + if (this.kcSession == null) { + throw new SetupException("No KeycloakSession provided in ThreadLocal"); + } + } + + @Override + public SqlStatement[] generateStatements(Database database) throws CustomChangeException { + this.database = database; + jdbcConnection = (JdbcConnection) database.getConnection(); + connection = jdbcConnection.getWrappedConnection(); + + if (isApplicable()) { + confirmationMessage.append(getTaskId() + ": "); + generateStatementsImpl(); + } else { + confirmationMessage.append(getTaskId() + ": no update applicable for this task"); + } + + return statements.toArray(new SqlStatement[statements.size()]); + } + + protected boolean isApplicable() throws CustomChangeException { + try { + String correctedTableName = database.correctObjectName("REALM", Table.class); + if (SnapshotGeneratorFactory.getInstance().has(new Table().setName(correctedTableName), database)) { + ResultSet resultSet = connection.createStatement().executeQuery("SELECT ID FROM REALM"); + try { + return (resultSet.next()); + } finally { + resultSet.close(); + } + } else { + return false; + } + } catch (Exception e) { + throw new CustomChangeException("Failed to check database availability", e); + } + } + + /** + * It's supposed to fill SQL statements to the "statements" variable and fill "confirmationMessage" + */ + protected abstract void generateStatementsImpl() throws CustomChangeException; + + protected abstract String getTaskId(); +} diff --git a/connections/jpa-liquibase/src/main/java/org/keycloak/connections/jpa/updater/liquibase/custom/JpaUpdate1_2_0_Beta1.java b/connections/jpa-liquibase/src/main/java/org/keycloak/connections/jpa/updater/liquibase/custom/JpaUpdate1_2_0_Beta1.java new file mode 100644 index 0000000000..b28dce5512 --- /dev/null +++ b/connections/jpa-liquibase/src/main/java/org/keycloak/connections/jpa/updater/liquibase/custom/JpaUpdate1_2_0_Beta1.java @@ -0,0 +1,337 @@ +package org.keycloak.connections.jpa.updater.liquibase.custom; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import liquibase.exception.CustomChangeException; +import liquibase.exception.DatabaseException; +import liquibase.statement.core.InsertStatement; +import liquibase.statement.core.UpdateStatement; +import liquibase.structure.core.Table; +import org.keycloak.Config; +import org.keycloak.models.AdminRoles; +import org.keycloak.models.ClaimMask; +import org.keycloak.models.ProtocolMapperModel; +import org.keycloak.models.utils.KeycloakModelUtils; +import org.keycloak.services.util.MigrationUtils; + +/** + * @author Marek Posolda + */ +public class JpaUpdate1_2_0_Beta1 extends CustomKeycloakTask { + + private String realmTableName; + + @Override + protected String getTaskId() { + return "Update 1.2.0.Beta1"; + } + + @Override + protected void generateStatementsImpl() throws CustomChangeException { + realmTableName = database.correctObjectName("REALM", Table.class); + + try { + convertSocialToIdFedRealms(); + convertSocialToIdFedUsers(); + addAccessCodeLoginTimeout(); + addNewAdminRoles(); + addDefaultProtocolMappers(); + } catch (Exception e) { + throw new CustomChangeException(getTaskId() + ": Exception when updating data from previous version", e); + } + } + + + protected void convertSocialToIdFedRealms() throws SQLException, DatabaseException { + String identityProviderTableName = database.correctObjectName("IDENTITY_PROVIDER", Table.class); + String idpConfigTableName = database.correctObjectName("IDENTITY_PROVIDER_CONFIG", Table.class); + + PreparedStatement statement = jdbcConnection.prepareStatement("select RSC.NAME, VALUE, REALM_ID, UPDATE_PROFILE_ON_SOC_LOGIN from REALM_SOCIAL_CONFIG RSC,REALM where RSC.REALM_ID = REALM.ID ORDER BY RSC.REALM_ID, RSC.NAME"); + try { + ResultSet resultSet = statement.executeQuery(); + try { + boolean providerInProgress = false; + String socialProviderId = null; + String clientId = null; + String clientSecret; + String realmId = null; + boolean updateProfileOnSocialLogin = false; + boolean first = true; + + while (resultSet.next()) { + if (first) { + confirmationMessage.append("Migrating social to identity providers: "); + first = false; + } + + if (!providerInProgress) { + String key = resultSet.getString("NAME"); + int keyIndex = key.indexOf(".key"); + if (keyIndex == -1) { + throw new IllegalStateException("Can't parse the provider from column: " + key); + } + + socialProviderId = key.substring(0, keyIndex); + clientId = resultSet.getString("VALUE"); + realmId = resultSet.getString("REALM_ID"); + updateProfileOnSocialLogin = resultSet.getBoolean("UPDATE_PROFILE_ON_SOC_LOGIN"); + providerInProgress = true; + } else { + clientSecret = resultSet.getString("VALUE"); + + String internalId = KeycloakModelUtils.generateId(); + InsertStatement idpInsert = new InsertStatement(null, null, identityProviderTableName) + .addColumnValue("INTERNAL_ID", internalId) + .addColumnValue("ENABLED", true) + .addColumnValue("PROVIDER_ALIAS", socialProviderId) + .addColumnValue("PROVIDER_ID", socialProviderId) + .addColumnValue("UPDATE_PROFILE_FIRST_LOGIN", updateProfileOnSocialLogin) + .addColumnValue("STORE_TOKEN", false) + .addColumnValue("AUTHENTICATE_BY_DEFAULT", false) + .addColumnValue("REALM_ID", realmId); + InsertStatement clientIdInsert = new InsertStatement(null, null, idpConfigTableName) + .addColumnValue("IDENTITY_PROVIDER_ID", internalId) + .addColumnValue("NAME", "clientId") + .addColumnValue("VALUE", clientId); + InsertStatement clientSecretInsert = new InsertStatement(null, null, idpConfigTableName) + .addColumnValue("IDENTITY_PROVIDER_ID", internalId) + .addColumnValue("NAME", "clientSecret") + .addColumnValue("VALUE", clientSecret); + + statements.add(idpInsert); + statements.add(clientIdInsert); + statements.add(clientSecretInsert); + confirmationMessage.append(socialProviderId + " in realm " + realmId + ", "); + + providerInProgress = false; + } + } + + // It means that some provider where processed + if (!first) { + confirmationMessage.append(". "); + } + } finally { + resultSet.close(); + } + } finally { + statement.close(); + } + } + + protected void convertSocialToIdFedUsers() throws SQLException, DatabaseException { + String federatedIdentityTableName = database.correctObjectName("FEDERATED_IDENTITY", Table.class); + PreparedStatement statement = jdbcConnection.prepareStatement("select REALM_ID, USER_ID, SOCIAL_PROVIDER, SOCIAL_USER_ID, SOCIAL_USERNAME from USER_SOCIAL_LINK"); + try { + ResultSet resultSet = statement.executeQuery(); + try { + int count = 0; + while (resultSet.next()) { + InsertStatement insert = new InsertStatement(null, null, federatedIdentityTableName) + .addColumnValue("REALM_ID", resultSet.getString("REALM_ID")) + .addColumnValue("USER_ID", resultSet.getString("USER_ID")) + .addColumnValue("IDENTITY_PROVIDER", resultSet.getString("SOCIAL_PROVIDER")) + .addColumnValue("FEDERATED_USER_ID", resultSet.getString("SOCIAL_USER_ID")) + .addColumnValue("FEDERATED_USERNAME", resultSet.getString("SOCIAL_USERNAME")); + count++; + statements.add(insert); + } + + confirmationMessage.append("Updating " + count + " social links to federated identities. "); + } finally { + resultSet.close(); + } + } finally { + statement.close(); + } + } + + protected void addAccessCodeLoginTimeout() { + UpdateStatement statement = new UpdateStatement(null, null, realmTableName) + .addNewColumnValue("LOGIN_LIFESPAN", 1800) + .setWhereClause("LOGIN_LIFESPAN IS NULL"); + statements.add(statement); + + confirmationMessage.append("Updated LOGIN_LIFESPAN of all realms to 1800 seconds. "); + } + + private void addNewAdminRoles() throws SQLException, DatabaseException{ + addNewMasterAdminRoles(); + addNewRealmAdminRoles(); + + confirmationMessage.append("Adding new admin roles. "); + } + + protected void addNewMasterAdminRoles() throws SQLException, DatabaseException { + // Retrieve ID of admin role of master realm + String adminRoleId = getAdminRoleId(); + String masterRealmId = Config.getAdminRealm(); + + PreparedStatement statement = jdbcConnection.prepareStatement("select NAME from REALM"); + try { + ResultSet resultSet = statement.executeQuery(); + try { + while (resultSet.next()) { + String realmName = resultSet.getString("NAME"); + String masterAdminAppName = realmName + "-realm"; + + PreparedStatement statement2 = jdbcConnection.prepareStatement("select ID from CLIENT where REALM_ID = ? AND NAME = ?"); + statement2.setString(1, masterRealmId); + statement2.setString(2, masterAdminAppName); + + try { + ResultSet resultSet2 = statement2.executeQuery(); + try { + if (resultSet2.next()) { + String masterAdminAppId = resultSet2.getString("ID"); + + addAdminRole(AdminRoles.VIEW_IDENTITY_PROVIDERS, masterRealmId, masterAdminAppId, adminRoleId); + addAdminRole(AdminRoles.MANAGE_IDENTITY_PROVIDERS, masterRealmId, masterAdminAppId, adminRoleId); + } else { + throw new IllegalStateException("Couldn't find ID of '" + masterAdminAppName + "' application in 'master' realm. "); + } + } finally { + resultSet2.close(); + } + } finally { + statement2.close(); + } + } + } finally { + resultSet.close(); + } + } finally { + statement.close(); + } + } + + private String getAdminRoleId() throws SQLException, DatabaseException { + PreparedStatement statement = jdbcConnection.prepareStatement("select ID from KEYCLOAK_ROLE where NAME = ? AND REALM = ?"); + statement.setString(1, AdminRoles.ADMIN); + statement.setString(2, Config.getAdminRealm()); + + try { + ResultSet resultSet = statement.executeQuery(); + try { + if (resultSet.next()) { + return resultSet.getString("ID"); + } else { + throw new IllegalStateException("Couldn't find ID of 'admin' role in 'master' realm"); + } + } finally { + resultSet.close(); + } + } finally { + statement.close(); + } + } + + + protected void addNewRealmAdminRoles() throws SQLException, DatabaseException { + PreparedStatement statement = jdbcConnection.prepareStatement("select CLIENT.ID REALM_ADMIN_APP_ID, CLIENT.REALM_ID REALM_ID, KEYCLOAK_ROLE.ID ADMIN_ROLE_ID from CLIENT,KEYCLOAK_ROLE where KEYCLOAK_ROLE.APPLICATION = CLIENT.ID AND CLIENT.NAME = 'realm-management' AND KEYCLOAK_ROLE.NAME = ?"); + statement.setString(1, AdminRoles.REALM_ADMIN); + + try { + ResultSet resultSet = statement.executeQuery(); + try { + + while (resultSet.next()) { + String realmAdminAppId = resultSet.getString("REALM_ADMIN_APP_ID"); + String realmId = resultSet.getString("REALM_ID"); + String adminRoleId = resultSet.getString("ADMIN_ROLE_ID"); + + addAdminRole(AdminRoles.VIEW_IDENTITY_PROVIDERS, realmId, realmAdminAppId, adminRoleId); + addAdminRole(AdminRoles.MANAGE_IDENTITY_PROVIDERS, realmId, realmAdminAppId, adminRoleId); + } + } finally { + resultSet.close(); + } + } finally { + statement.close(); + } + } + + private void addAdminRole(String roleName, String realmId, String applicationId, String realmAdminAppRoleId) { + String roleTableName = database.correctObjectName("KEYCLOAK_ROLE", Table.class); + String compositeRoleTableName = database.correctObjectName("COMPOSITE_ROLE", Table.class); + String newRoleId = KeycloakModelUtils.generateId(); + + InsertStatement insertRole = new InsertStatement(null, null, roleTableName) + .addColumnValue("ID", newRoleId) + .addColumnValue("APP_REALM_CONSTRAINT", applicationId) + .addColumnValue("APPLICATION_ROLE", true) + .addColumnValue("NAME", roleName) + .addColumnValue("REALM_ID", realmId) + .addColumnValue("APPLICATION", applicationId); + + // Add newly created role to the composite roles of 'realm-admin' role + InsertStatement insertCompRole = new InsertStatement(null, null, compositeRoleTableName) + .addColumnValue("COMPOSITE", realmAdminAppRoleId) + .addColumnValue("CHILD_ROLE", newRoleId); + + statements.add(insertRole); + statements.add(insertCompRole); + } + + protected void addDefaultProtocolMappers() throws SQLException, DatabaseException { + String protocolMapperTableName = database.correctObjectName("PROTOCOL_MAPPER", Table.class); + String protocolMapperCfgTableName = database.correctObjectName("PROTOCOL_MAPPER_CONFIG", Table.class); + + PreparedStatement statement = jdbcConnection.prepareStatement("select ID, NAME, ALLOWED_CLAIMS_MASK from CLIENT"); + + try { + ResultSet resultSet = statement.executeQuery(); + try { + boolean first = true; + while (resultSet.next()) { + if (first) { + confirmationMessage.append("Migrating claimsMask to protocol mappers for clients: "); + first = false; + } + + Object acmObj = resultSet.getObject("ALLOWED_CLAIMS_MASK"); + long mask = (acmObj != null) ? (Long) acmObj : ClaimMask.ALL; + + Collection protocolMappers = MigrationUtils.getMappersForClaimMask(this.kcSession, mask); + for (ProtocolMapperModel protocolMapper : protocolMappers) { + String mapperId = KeycloakModelUtils.generateId(); + + InsertStatement insert = new InsertStatement(null, null, protocolMapperTableName) + .addColumnValue("ID", mapperId) + .addColumnValue("PROTOCOL", protocolMapper.getProtocol()) + .addColumnValue("NAME", protocolMapper.getName()) + .addColumnValue("CONSENT_REQUIRED", protocolMapper.isConsentRequired()) + .addColumnValue("CONSENT_TEXT", protocolMapper.getConsentText()) + .addColumnValue("PROTOCOL_MAPPER_NAME", protocolMapper.getProtocolMapper()) + .addColumnValue("CLIENT_ID", resultSet.getString("ID")); + statements.add(insert); + + for (Map.Entry cfgEntry : protocolMapper.getConfig().entrySet()) { + InsertStatement cfgInsert = new InsertStatement(null, null, protocolMapperCfgTableName) + .addColumnValue("PROTOCOL_MAPPER_ID", mapperId) + .addColumnValue("NAME", cfgEntry.getKey()) + .addColumnValue("VALUE", cfgEntry.getValue()); + statements.add(cfgInsert); + } + + } + + confirmationMessage.append(resultSet.getString("NAME") + ", "); + } + + // It means that some provider where processed + if (!first) { + confirmationMessage.append(". "); + } + } finally { + resultSet.close(); + } + } finally { + statement.close(); + } + } +} diff --git a/connections/jpa-liquibase/src/main/resources/META-INF/jpa-changelog-1.2.0.Beta1.xml b/connections/jpa-liquibase/src/main/resources/META-INF/jpa-changelog-1.2.0.Beta1.xml index a97473d77a..9459ca3d53 100755 --- a/connections/jpa-liquibase/src/main/resources/META-INF/jpa-changelog-1.2.0.Beta1.xml +++ b/connections/jpa-liquibase/src/main/resources/META-INF/jpa-changelog-1.2.0.Beta1.xml @@ -19,9 +19,6 @@ - - - @@ -153,5 +150,15 @@ + + + + + + + + + + diff --git a/connections/jpa/src/main/java/org/keycloak/connections/jpa/DefaultJpaConnectionProviderFactory.java b/connections/jpa/src/main/java/org/keycloak/connections/jpa/DefaultJpaConnectionProviderFactory.java index b15755c444..e66ad65970 100755 --- a/connections/jpa/src/main/java/org/keycloak/connections/jpa/DefaultJpaConnectionProviderFactory.java +++ b/connections/jpa/src/main/java/org/keycloak/connections/jpa/DefaultJpaConnectionProviderFactory.java @@ -140,7 +140,7 @@ public class DefaultJpaConnectionProviderFactory implements JpaConnectionProvide } if (currentVersion == null || !JpaUpdaterProvider.LAST_VERSION.equals(currentVersion)) { - updater.update(connection); + updater.update(session, connection); } else { logger.debug("Database is up to date"); } diff --git a/connections/jpa/src/main/java/org/keycloak/connections/jpa/updater/JpaUpdaterProvider.java b/connections/jpa/src/main/java/org/keycloak/connections/jpa/updater/JpaUpdaterProvider.java index c5dc31f471..f3355e01d8 100755 --- a/connections/jpa/src/main/java/org/keycloak/connections/jpa/updater/JpaUpdaterProvider.java +++ b/connections/jpa/src/main/java/org/keycloak/connections/jpa/updater/JpaUpdaterProvider.java @@ -1,5 +1,6 @@ package org.keycloak.connections.jpa.updater; +import org.keycloak.models.KeycloakSession; import org.keycloak.provider.Provider; import java.sql.Connection; @@ -15,7 +16,7 @@ public interface JpaUpdaterProvider extends Provider { public String getCurrentVersionSql(); - public void update(Connection connection); + public void update(KeycloakSession session, Connection connection); public void validate(Connection connection); diff --git a/distribution/modules/src/main/resources/modules/org/keycloak/keycloak-connections-jpa-liquibase/main/module.xml b/distribution/modules/src/main/resources/modules/org/keycloak/keycloak-connections-jpa-liquibase/main/module.xml index f52bc84788..c5b7d85166 100755 --- a/distribution/modules/src/main/resources/modules/org/keycloak/keycloak-connections-jpa-liquibase/main/module.xml +++ b/distribution/modules/src/main/resources/modules/org/keycloak/keycloak-connections-jpa-liquibase/main/module.xml @@ -10,6 +10,7 @@ +