From 74c1511e0ce91332deb5d6e08490d2e870af2220 Mon Sep 17 00:00:00 2001 From: maeberl Date: Mon, 13 Apr 2026 09:47:51 +0200 Subject: [PATCH 1/4] fix: MigrateTo26_6_0 should only add organization step to built-in browser flow Closes #47908 Signed-off-by: maeberl --- .../java/org/keycloak/migration/migrators/MigrateTo26_6_0.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/model/storage-private/src/main/java/org/keycloak/migration/migrators/MigrateTo26_6_0.java b/model/storage-private/src/main/java/org/keycloak/migration/migrators/MigrateTo26_6_0.java index f95ef2dd3e18..dca913598d08 100644 --- a/model/storage-private/src/main/java/org/keycloak/migration/migrators/MigrateTo26_6_0.java +++ b/model/storage-private/src/main/java/org/keycloak/migration/migrators/MigrateTo26_6_0.java @@ -18,6 +18,6 @@ public ModelVersion getVersion() { @Override public void migrateRealm(KeycloakSession session, RealmModel realm) { - DefaultAuthenticationFlows.addOrganizationBrowserFlowStep(realm, realm.getBrowserFlow()); + DefaultAuthenticationFlows.addOrganizationBrowserFlowStep(realm, realm.getFlowByAlias(DefaultAuthenticationFlows.BROWSER_FLOW)); } } From d1ad2c32df6a1d770d5a287ed748972ccd13ba42 Mon Sep 17 00:00:00 2001 From: Pedro Igor Date: Mon, 13 Apr 2026 14:51:42 -0300 Subject: [PATCH 2/4] Running browser flow organization migration on 26.6.1 Signed-off-by: Pedro Igor --- .../upgrading/topics/changes/changes-26_6_1.adoc | 11 +++++++++++ .../{MigrateTo26_6_0.java => MigrateTo26_6_1.java} | 2 +- .../storage/datastore/DefaultMigrationManager.java | 4 ++-- 3 files changed, 14 insertions(+), 3 deletions(-) rename model/storage-private/src/main/java/org/keycloak/migration/migrators/{MigrateTo26_6_0.java => MigrateTo26_6_1.java} (92%) diff --git a/docs/documentation/upgrading/topics/changes/changes-26_6_1.adoc b/docs/documentation/upgrading/topics/changes/changes-26_6_1.adoc index 3c45e7e348d1..75aa3ff4297a 100644 --- a/docs/documentation/upgrading/topics/changes/changes-26_6_1.adoc +++ b/docs/documentation/upgrading/topics/changes/changes-26_6_1.adoc @@ -1,3 +1,14 @@ +== Manual migration if you migrated to 26.6.0 + +The migration to 26.6.0 might have introduced the `Organization` sub-flow to your custom browser flow in case they were bound to the `browser flow` binding type. + +If you upgrade from 26.5.x or earlier directly to 26.6.1, this does not apply to you. + +If you have migrated to 26.6.0, review your custom browser flow in each realm and remove the `Organization` sub-flow if this is not desired. + +Once you upgraded to 26.6.1, you should see in the built-in browser flow the `Organization` sub-flow. + +// ------------------------ Breaking changes ------------------------ // == Breaking changes Breaking changes are identified as those that might require changes for existing users to their configurations or applications. diff --git a/model/storage-private/src/main/java/org/keycloak/migration/migrators/MigrateTo26_6_0.java b/model/storage-private/src/main/java/org/keycloak/migration/migrators/MigrateTo26_6_1.java similarity index 92% rename from model/storage-private/src/main/java/org/keycloak/migration/migrators/MigrateTo26_6_0.java rename to model/storage-private/src/main/java/org/keycloak/migration/migrators/MigrateTo26_6_1.java index dca913598d08..0400596aaf9d 100644 --- a/model/storage-private/src/main/java/org/keycloak/migration/migrators/MigrateTo26_6_0.java +++ b/model/storage-private/src/main/java/org/keycloak/migration/migrators/MigrateTo26_6_1.java @@ -6,7 +6,7 @@ import org.keycloak.models.utils.DefaultAuthenticationFlows; -public class MigrateTo26_6_0 extends RealmMigration { +public class MigrateTo26_6_1 extends RealmMigration { public static final ModelVersion VERSION = new ModelVersion("26.6.0"); diff --git a/model/storage-private/src/main/java/org/keycloak/storage/datastore/DefaultMigrationManager.java b/model/storage-private/src/main/java/org/keycloak/storage/datastore/DefaultMigrationManager.java index fcf8b1e08e80..fd4b53127374 100644 --- a/model/storage-private/src/main/java/org/keycloak/storage/datastore/DefaultMigrationManager.java +++ b/model/storage-private/src/main/java/org/keycloak/storage/datastore/DefaultMigrationManager.java @@ -49,7 +49,7 @@ import org.keycloak.migration.migrators.MigrateTo26_3_0; import org.keycloak.migration.migrators.MigrateTo26_4_0; import org.keycloak.migration.migrators.MigrateTo26_4_3; -import org.keycloak.migration.migrators.MigrateTo26_6_0; +import org.keycloak.migration.migrators.MigrateTo26_6_1; import org.keycloak.migration.migrators.MigrateTo2_0_0; import org.keycloak.migration.migrators.MigrateTo2_1_0; import org.keycloak.migration.migrators.MigrateTo2_2_0; @@ -133,7 +133,7 @@ public class DefaultMigrationManager implements MigrationManager { new MigrateTo26_3_0(), new MigrateTo26_4_0(), new MigrateTo26_4_3(), - new MigrateTo26_6_0() + new MigrateTo26_6_1() }; private final KeycloakSession session; From 4e7c9bee40df325f8b47a9a45e2ad309f7c717cf Mon Sep 17 00:00:00 2001 From: Alexander Schwartz Date: Tue, 14 Apr 2026 08:49:37 +0200 Subject: [PATCH 3/4] Review - update the VERSION, rewording the migration Signed-off-by: Alexander Schwartz --- .../java/org/keycloak/migration/migrators/MigrateTo26_6_1.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/model/storage-private/src/main/java/org/keycloak/migration/migrators/MigrateTo26_6_1.java b/model/storage-private/src/main/java/org/keycloak/migration/migrators/MigrateTo26_6_1.java index 0400596aaf9d..b54c84096507 100644 --- a/model/storage-private/src/main/java/org/keycloak/migration/migrators/MigrateTo26_6_1.java +++ b/model/storage-private/src/main/java/org/keycloak/migration/migrators/MigrateTo26_6_1.java @@ -8,7 +8,7 @@ public class MigrateTo26_6_1 extends RealmMigration { - public static final ModelVersion VERSION = new ModelVersion("26.6.0"); + public static final ModelVersion VERSION = new ModelVersion("26.6.1"); @Override public ModelVersion getVersion() { From 0f67543fd902542ad53e643ca3b0d6dab269614b Mon Sep 17 00:00:00 2001 From: Pedro Igor Date: Tue, 14 Apr 2026 08:24:33 -0300 Subject: [PATCH 4/4] Allow deleting subflows and executions if the parent is not a built-in flow Signed-off-by: Pedro Igor --- .../migration/migrators/MigrateTo22_0_0.java | 2 +- .../models/utils/KeycloakModelUtils.java | 10 ++--- .../AuthenticationManagementResource.java | 6 ++- .../tests/admin/authentication/FlowTest.java | 39 +++++++++++++++++++ 4 files changed, 49 insertions(+), 8 deletions(-) diff --git a/model/storage-private/src/main/java/org/keycloak/migration/migrators/MigrateTo22_0_0.java b/model/storage-private/src/main/java/org/keycloak/migration/migrators/MigrateTo22_0_0.java index 1df65f679482..4b496f8a4bee 100644 --- a/model/storage-private/src/main/java/org/keycloak/migration/migrators/MigrateTo22_0_0.java +++ b/model/storage-private/src/main/java/org/keycloak/migration/migrators/MigrateTo22_0_0.java @@ -60,7 +60,7 @@ private void removeHttpChallengeFlow(KeycloakSession session, RealmModel realm) if (httpChallenge == null) return; try { - KeycloakModelUtils.deepDeleteAuthenticationFlow(session, realm, httpChallenge, () -> {}, () -> {}); + KeycloakModelUtils.deepDeleteAuthenticationFlow(session, realm, httpChallenge, () -> {}, () -> {}, httpChallenge.isBuiltIn()); LOG.debugf("Removed '%s' authentication flow in realm '%s'", HTTP_CHALLENGE_FLOW, realm.getName()); } catch (ModelException me) { LOG.errorf("Authentication flow '%s' is in use in realm '%s' and cannot be removed. Please update your deployment to avoid using this flow before migration to latest Keycloak", diff --git a/server-spi-private/src/main/java/org/keycloak/models/utils/KeycloakModelUtils.java b/server-spi-private/src/main/java/org/keycloak/models/utils/KeycloakModelUtils.java index 42bd3ea8b377..1323c496d8f0 100755 --- a/server-spi-private/src/main/java/org/keycloak/models/utils/KeycloakModelUtils.java +++ b/server-spi-private/src/main/java/org/keycloak/models/utils/KeycloakModelUtils.java @@ -1147,17 +1147,17 @@ public static boolean isFlowUsed(KeycloakSession session, RealmModel realm, Auth * @param flowUnavailableHandler Will be executed when flow, sub-flow or executor is null * @param builtinFlowHandler will be executed when flow is built-in flow */ - public static void deepDeleteAuthenticationFlow(KeycloakSession session, RealmModel realm, AuthenticationFlowModel authFlow, Runnable flowUnavailableHandler, Runnable builtinFlowHandler) { + public static void deepDeleteAuthenticationFlow(KeycloakSession session, RealmModel realm, AuthenticationFlowModel authFlow, Runnable flowUnavailableHandler, Runnable builtinFlowHandler, boolean isParentBuiltInFlow) { if (authFlow == null) { flowUnavailableHandler.run(); return; } - if (authFlow.isBuiltIn()) { + if (isParentBuiltInFlow) { builtinFlowHandler.run(); } realm.getAuthenticationExecutionsStream(authFlow.getId()) - .forEachOrdered(authExecutor -> deepDeleteAuthenticationExecutor(session, realm, authExecutor, flowUnavailableHandler, builtinFlowHandler)); + .forEachOrdered(authExecutor -> deepDeleteAuthenticationExecutor(session, realm, authExecutor, flowUnavailableHandler, builtinFlowHandler, isParentBuiltInFlow)); realm.removeAuthenticationFlow(authFlow); } @@ -1171,7 +1171,7 @@ public static void deepDeleteAuthenticationFlow(KeycloakSession session, RealmMo * @param flowUnavailableHandler Handler that will be executed when flow, sub-flow or executor is null * @param builtinFlowHandler Handler that will be executed when flow is built-in flow */ - public static void deepDeleteAuthenticationExecutor(KeycloakSession session, RealmModel realm, AuthenticationExecutionModel authExecutor, Runnable flowUnavailableHandler, Runnable builtinFlowHandler) { + public static void deepDeleteAuthenticationExecutor(KeycloakSession session, RealmModel realm, AuthenticationExecutionModel authExecutor, Runnable flowUnavailableHandler, Runnable builtinFlowHandler, boolean isParentBuiltInFlow) { if (authExecutor == null) { flowUnavailableHandler.run(); return; @@ -1180,7 +1180,7 @@ public static void deepDeleteAuthenticationExecutor(KeycloakSession session, Rea // recursively remove sub flows if (authExecutor.getFlowId() != null) { AuthenticationFlowModel authFlow = realm.getAuthenticationFlowById(authExecutor.getFlowId()); - deepDeleteAuthenticationFlow(session, realm, authFlow, flowUnavailableHandler, builtinFlowHandler); + deepDeleteAuthenticationFlow(session, realm, authFlow, flowUnavailableHandler, builtinFlowHandler, isParentBuiltInFlow); } // remove the config if not shared diff --git a/services/src/main/java/org/keycloak/services/resources/admin/AuthenticationManagementResource.java b/services/src/main/java/org/keycloak/services/resources/admin/AuthenticationManagementResource.java index d30696f8cf4c..dba5a7c552b6 100755 --- a/services/src/main/java/org/keycloak/services/resources/admin/AuthenticationManagementResource.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/AuthenticationManagementResource.java @@ -379,7 +379,8 @@ public void deleteFlow(@Parameter(description = "Flow id") @PathParam("id") Stri () -> {}, // allow deleting even with missing references () -> { throw new BadRequestException("Can't delete built in flow"); - } + }, + flow.isBuiltIn() ); // Use just one event for top-level flow. Using separate events won't work properly for flows of depth 2 or bigger @@ -1034,7 +1035,8 @@ public void removeExecution(@Parameter(description = "Execution id") @PathParam( () -> {}, // allow deleting even with missing references () -> { throw new BadRequestException("It is illegal to remove execution from a built in flow"); - } + }, + parentFlow.isBuiltIn() ); adminEvent.operation(OperationType.DELETE).resource(ResourceType.AUTH_EXECUTION).resourcePath(session.getContext().getUri()).success(); diff --git a/tests/base/src/test/java/org/keycloak/tests/admin/authentication/FlowTest.java b/tests/base/src/test/java/org/keycloak/tests/admin/authentication/FlowTest.java index 9aa2adbf095b..570993d64cb9 100644 --- a/tests/base/src/test/java/org/keycloak/tests/admin/authentication/FlowTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/admin/authentication/FlowTest.java @@ -123,6 +123,45 @@ public void testRemoveExecutionSubflow() { addFlowToParent("child", "grandchild"); } + @Test + public void testRemoveBuiltinSubflowFromCustomBrowserFlow() { + // Create a custom top-level browser flow that is NOT marked as builtin + createFlow(newFlow("CustomBrowser", "Custom Browser flow", "basic-flow", true, false)); + + // Add a sub-flow to the custom browser flow (it is not builtin by default) + addFlowToParent("CustomBrowser", "builtin-child"); + + // Locate the child sub-flow execution in the parent flow + List executions = authMgmtResource.getExecutions("CustomBrowser"); + AuthenticationExecutionInfoRepresentation childExecution = executions.stream() + .filter(r -> "builtin-child".equals(r.getDisplayName()) && r.getLevel() == 0) + .findAny().orElse(null); + Assertions.assertNotNull(childExecution, "Expected to find the child sub-flow execution"); + + // Mark the sub-flow itself as builtin via the update API + String subFlowId = childExecution.getFlowId(); + Assertions.assertNotNull(subFlowId, "Expected the child execution to reference a sub-flow"); + AuthenticationFlowRepresentation subFlow = authMgmtResource.getFlow(subFlowId); + subFlow.setBuiltIn(true); + authMgmtResource.updateFlow(subFlowId, subFlow); + Assertions.assertTrue(authMgmtResource.getFlow(subFlowId).isBuiltIn(), + "Sub-flow should now be marked as builtin"); + + // Skip the admin events produced by addFlowToParent (CREATE AUTH_EXECUTION_FLOW) + // and updateFlow (UPDATE AUTH_FLOW) since they are not the focus of this test + adminEvents.skip(2); + + // Removing the builtin-marked sub-flow from a non-builtin parent flow must succeed + authMgmtResource.removeExecution(childExecution.getId()); + AdminEventAssertion.assertEvent(adminEvents.poll(), OperationType.DELETE, + AdminEventPaths.authExecutionPath(childExecution.getId()), ResourceType.AUTH_EXECUTION); + + // Verify the sub-flow execution is no longer present in the parent flow + executions = authMgmtResource.getExecutions("CustomBrowser"); + Assertions.assertTrue(executions.isEmpty(), + "Expected no executions remaining in the custom browser flow after removal"); + } + private void addFlowToParent(String parentAlias, String childAlias) { Map data = new HashMap<>(); data.put("alias", childAlias);