From 51a55b9c00721957897ed9dc04aee85348779951 Mon Sep 17 00:00:00 2001 From: rtufisi <160157925+rtufisi@users.noreply.github.com> Date: Tue, 10 Dec 2024 20:43:21 +0200 Subject: [PATCH] #280 Add support for shared IDPs import/export (#285) --- .../KeycloakOrgsImportConverter.java | 34 ++++- .../resource/OrganizationsResource.java | 2 +- .../OrganizationIdpExportTest.java | 120 ++++++++++++++++ .../OrganizationSharedIdpExportTest.java | 134 ++++++++++++++++++ 4 files changed, 282 insertions(+), 8 deletions(-) create mode 100644 src/test/java/io/phasetwo/service/importexport/OrganizationSharedIdpExportTest.java diff --git a/src/main/java/io/phasetwo/service/importexport/KeycloakOrgsImportConverter.java b/src/main/java/io/phasetwo/service/importexport/KeycloakOrgsImportConverter.java index 511a8e65..20fbe091 100644 --- a/src/main/java/io/phasetwo/service/importexport/KeycloakOrgsImportConverter.java +++ b/src/main/java/io/phasetwo/service/importexport/KeycloakOrgsImportConverter.java @@ -1,7 +1,5 @@ package io.phasetwo.service.importexport; -import static io.phasetwo.service.Orgs.ORG_OWNER_CONFIG_KEY; - import io.phasetwo.service.importexport.representation.OrganizationAttributes; import io.phasetwo.service.importexport.representation.OrganizationRepresentation; import io.phasetwo.service.importexport.representation.OrganizationRoleRepresentation; @@ -13,12 +11,20 @@ import java.util.List; import java.util.Objects; import java.util.Set; + import lombok.extern.jbosslog.JBossLog; import org.keycloak.models.IdentityProviderModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.ModelException; import org.keycloak.models.RealmModel; import org.keycloak.models.utils.KeycloakModelUtils; +import org.keycloak.models.utils.ModelToRepresentation; +import org.keycloak.models.utils.RepresentationToModel; +import org.keycloak.representations.idm.IdentityProviderRepresentation; + +import static io.phasetwo.service.Orgs.ORG_CONFIG_SHARED_IDPS_KEY; +import static io.phasetwo.service.Orgs.ORG_OWNER_CONFIG_KEY; +import static io.phasetwo.service.Orgs.ORG_SHARED_IDP_KEY; @JBossLog public final class KeycloakOrgsImportConverter { @@ -109,13 +115,15 @@ public static void addMembers( } public static void createOrganizationIdp( - KeycloakSession session, String idpLink, OrganizationModel org, boolean skipMissingIdp) { + KeycloakSession session, RealmModel realm, String idpLink, OrganizationModel org, boolean skipMissingIdp) { if (Objects.nonNull(idpLink)) { IdentityProviderModel idp = session.identityProviders().getByAlias(idpLink); if (Objects.nonNull(idp)) { - IdentityProviders.setAttributeMultivalued( - idp.getConfig(), ORG_OWNER_CONFIG_KEY, Set.of(org.getId())); - session.identityProviders().update(idp); + IdentityProviderRepresentation representation = ModelToRepresentation.toRepresentation(realm, idp); + processsSharedIdps(realm, org, representation); + + IdentityProviderModel updated = RepresentationToModel.toModel(realm, representation, session); + session.identityProviders().update(updated); } else { if (skipMissingIdp) { log.debug( @@ -129,7 +137,19 @@ public static void createOrganizationIdp( } } - public static void createOrganizationRoles( + private static void processsSharedIdps(RealmModel realm, OrganizationModel org, IdentityProviderRepresentation representation) { + var isSharedIdpsConfigEnabled = realm.getAttribute(ORG_CONFIG_SHARED_IDPS_KEY, false); + + if (isSharedIdpsConfigEnabled) { + IdentityProviders.addMultiOrganization(org, representation); + } else { + representation.getConfig().put(ORG_SHARED_IDP_KEY, "false"); + IdentityProviders.setAttributeMultivalued( + representation.getConfig(), ORG_OWNER_CONFIG_KEY, Set.of(org.getId())); + } + } + + public static void createOrganizationRoles( List roles, OrganizationModel org) { roles.stream() .filter( diff --git a/src/main/java/io/phasetwo/service/resource/OrganizationsResource.java b/src/main/java/io/phasetwo/service/resource/OrganizationsResource.java index 1641555c..d0c0ac0f 100644 --- a/src/main/java/io/phasetwo/service/resource/OrganizationsResource.java +++ b/src/main/java/io/phasetwo/service/resource/OrganizationsResource.java @@ -314,7 +314,7 @@ private void createOrganization( KeycloakOrgsImportConverter.createOrganizationRoles( organizationRepresentation.getRoles(), org); - KeycloakOrgsImportConverter.createOrganizationIdp(session, organizationRepresentation.getIdpLink(), org, skipMissingIdp); + KeycloakOrgsImportConverter.createOrganizationIdp(session, realm, organizationRepresentation.getIdpLink(), org, skipMissingIdp); KeycloakOrgsImportConverter.addMembers( session, realm, organizationRepresentation, org, skipMissingMember); diff --git a/src/test/java/io/phasetwo/service/importexport/OrganizationIdpExportTest.java b/src/test/java/io/phasetwo/service/importexport/OrganizationIdpExportTest.java index 92f2f5ce..40a8fb1f 100644 --- a/src/test/java/io/phasetwo/service/importexport/OrganizationIdpExportTest.java +++ b/src/test/java/io/phasetwo/service/importexport/OrganizationIdpExportTest.java @@ -3,12 +3,21 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.Matchers.is; +import static org.junit.Assert.assertTrue; +import com.fasterxml.jackson.core.JsonProcessingException; import com.google.common.collect.ImmutableMap; +import io.phasetwo.client.openapi.model.OrganizationRepresentation; import io.phasetwo.service.AbstractOrganizationTest; import io.phasetwo.service.representation.LinkIdp; import java.io.IOException; +import java.util.List; + +import io.phasetwo.service.representation.OrganizationsConfig; import lombok.extern.jbosslog.JBossLog; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @JBossLog @@ -71,4 +80,115 @@ void testOrganizationLinkIdp() throws IOException { // delete idp keycloak.realm(REALM).identityProviders().get(alias1).remove(); } + + @Test + void testOrganizationChangeLink() throws IOException { + // create organization + var organization1 = + createOrganization( + new OrganizationRepresentation() + .name("example-org") + .domains(List.of("example.com", "test.org"))); + // create organization + var organization2 = + createOrganization( + new OrganizationRepresentation() + .name("example-org2") + .domains(List.of("example2.com", "test2.org"))); + + // create identity provider + String alias1 = "linking-provider-1"; + org.keycloak.representations.idm.IdentityProviderRepresentation idp = + new org.keycloak.representations.idm.IdentityProviderRepresentation(); + idp.setAlias(alias1); + idp.setProviderId("oidc"); + idp.setEnabled(true); + idp.setFirstBrokerLoginFlowAlias("first broker login"); + idp.setConfig( + new ImmutableMap.Builder() + .put("useJwksUrl", "true") + .put("syncMode", "FORCE") + .put("authorizationUrl", "https://foo.com") + .put("hideOnLoginPage", "") + .put("loginHint", "") + .put("uiLocales", "") + .put("backchannelSupported", "") + .put("disableUserInfo", "") + .put("acceptsPromptNoneForwardFromClient", "") + .put("validateSignature", "") + .put("pkceEnabled", "") + .put("tokenUrl", "https://foo.com") + .put("clientAuthMethod", "client_secret_post") + .put("clientId", "aabbcc") + .put("clientSecret", "112233") + .build()); + keycloak.realm(REALM).identityProviders().create(idp); + + // link org1 + LinkIdp link1 = new LinkIdp(); + link1.setAlias(alias1); + link1.setSyncMode("IMPORT"); + var responseOrg1Link = postRequest(link1, organization1.getId(), "idps", "link"); + assertThat( + responseOrg1Link.getStatusCode(), + is(jakarta.ws.rs.core.Response.Status.CREATED.getStatusCode())); + + // link org2 + var link2 = new LinkIdp(); + link2.setAlias(alias1); + link2.setSyncMode("IMPORT"); + var responseOrg2Link = postRequest(link2, organization2.getId(), "idps", "link"); + assertThat( + responseOrg2Link.getStatusCode(), + is(jakarta.ws.rs.core.Response.Status.CREATED.getStatusCode())); + + // export + var export = exportOrgs(keycloak, true); + assertThat(export.getOrganizations(), hasSize(2)); + + // validate org1 + export.getOrganizations().stream() + .filter(exportOrg -> exportOrg.getOrganization().getName().equals(organization1.getName())) + .findFirst() + .ifPresent(exportOrg -> Assertions.assertNull(exportOrg.getIdpLink())); + + // validate org2 + export.getOrganizations().stream() + .filter(exportOrg -> exportOrg.getOrganization().getName().equals(organization2.getName())) + .findFirst() + .ifPresent(exportOrg -> assertThat(exportOrg.getIdpLink(), is(alias1))); + + // delete org1 + deleteOrganization(organization1.getId()); + + // delete org2 + deleteOrganization(organization2.getId()); + + // delete idp + keycloak.realm(REALM).identityProviders().get(alias1).remove(); + } + + @BeforeEach + public void beforeEach() throws JsonProcessingException { + // add shared idp master config + var url = getAuthUrl() + "/realms/master/orgs/config"; + var orgConfig = new OrganizationsConfig(); + orgConfig.setSharedIdps(false); + var responseOrgsConfig = putRequest(orgConfig, url); + assertThat( + responseOrgsConfig.getStatusCode(), + is(jakarta.ws.rs.core.Response.Status.OK.getStatusCode())); + } + + @AfterEach + public void afterEach() throws JsonProcessingException { + // remove shared idp master config + var url = getAuthUrl() + "/realms/master/orgs/config"; + var orgConfig = new OrganizationsConfig(); + orgConfig.setSharedIdps(false); + var responseOrgsConfig = putRequest(orgConfig, url); + assertThat( + responseOrgsConfig.getStatusCode(), + is(jakarta.ws.rs.core.Response.Status.OK.getStatusCode())); + } } diff --git a/src/test/java/io/phasetwo/service/importexport/OrganizationSharedIdpExportTest.java b/src/test/java/io/phasetwo/service/importexport/OrganizationSharedIdpExportTest.java new file mode 100644 index 00000000..db97c615 --- /dev/null +++ b/src/test/java/io/phasetwo/service/importexport/OrganizationSharedIdpExportTest.java @@ -0,0 +1,134 @@ +package io.phasetwo.service.importexport; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.google.common.collect.ImmutableMap; +import io.phasetwo.client.openapi.model.OrganizationRepresentation; +import io.phasetwo.service.AbstractOrganizationTest; +import io.phasetwo.service.representation.LinkIdp; +import io.phasetwo.service.representation.OrganizationsConfig; +import lombok.extern.jbosslog.JBossLog; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.List; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; + +@JBossLog +public class OrganizationSharedIdpExportTest extends AbstractOrganizationTest { + + @Test + void testMultiOrganizationLink() throws IOException { + // create organization + var organization1 = + createOrganization( + new OrganizationRepresentation() + .name("example-org") + .domains(List.of("example.com", "test.org"))); + // create organization + var organization2 = + createOrganization( + new OrganizationRepresentation() + .name("example-org2") + .domains(List.of("example2.com", "test2.org"))); + + // create identity provider + String alias1 = "linking-provider-1"; + org.keycloak.representations.idm.IdentityProviderRepresentation idp = + new org.keycloak.representations.idm.IdentityProviderRepresentation(); + idp.setAlias(alias1); + idp.setProviderId("oidc"); + idp.setEnabled(true); + idp.setFirstBrokerLoginFlowAlias("first broker login"); + idp.setConfig( + new ImmutableMap.Builder() + .put("useJwksUrl", "true") + .put("syncMode", "FORCE") + .put("authorizationUrl", "https://foo.com") + .put("hideOnLoginPage", "") + .put("loginHint", "") + .put("uiLocales", "") + .put("backchannelSupported", "") + .put("disableUserInfo", "") + .put("acceptsPromptNoneForwardFromClient", "") + .put("validateSignature", "") + .put("pkceEnabled", "") + .put("tokenUrl", "https://foo.com") + .put("clientAuthMethod", "client_secret_post") + .put("clientId", "aabbcc") + .put("clientSecret", "112233") + .build()); + keycloak.realm(REALM).identityProviders().create(idp); + + // link org1 + LinkIdp link1 = new LinkIdp(); + link1.setAlias(alias1); + link1.setSyncMode("IMPORT"); + var responseOrg1Link = postRequest(link1, organization1.getId(), "idps", "link"); + assertThat( + responseOrg1Link.getStatusCode(), + is(jakarta.ws.rs.core.Response.Status.CREATED.getStatusCode())); + + // link org2 + var link2 = new LinkIdp(); + link2.setAlias(alias1); + link2.setSyncMode("IMPORT"); + var responseOrg2Link = postRequest(link2, organization2.getId(), "idps", "link"); + assertThat( + responseOrg2Link.getStatusCode(), + is(jakarta.ws.rs.core.Response.Status.CREATED.getStatusCode())); + + // export + var export = exportOrgs(keycloak, true); + assertThat(export.getOrganizations(), hasSize(2)); + + // validate org1 + export.getOrganizations().stream() + .filter(exportOrg -> exportOrg.getOrganization().getName().equals(organization1.getName())) + .findFirst() + .ifPresent(exportOrg -> assertThat(exportOrg.getIdpLink(), is(alias1))); + + // validate org2 + export.getOrganizations().stream() + .filter(exportOrg -> exportOrg.getOrganization().getName().equals(organization2.getName())) + .findFirst() + .ifPresent(exportOrg -> assertThat(exportOrg.getIdpLink(), is(alias1))); + + // delete org1 + deleteOrganization(organization1.getId()); + + // delete org2 + deleteOrganization(organization2.getId()); + + // delete idp + keycloak.realm(REALM).identityProviders().get(alias1).remove(); + } + + @BeforeEach + public void beforeEach() throws JsonProcessingException { + // add shared idp master config + var url = getAuthUrl() + "/realms/master/orgs/config"; + var orgConfig = new OrganizationsConfig(); + orgConfig.setSharedIdps(true); + var responseOrgsConfig = putRequest(orgConfig, url); + assertThat( + responseOrgsConfig.getStatusCode(), + is(jakarta.ws.rs.core.Response.Status.OK.getStatusCode())); + } + + @AfterEach + public void afterEach() throws JsonProcessingException { + // remove shared idp master config + var url = getAuthUrl() + "/realms/master/orgs/config"; + var orgConfig = new OrganizationsConfig(); + orgConfig.setSharedIdps(true); + var responseOrgsConfig = putRequest(orgConfig, url); + assertThat( + responseOrgsConfig.getStatusCode(), + is(jakarta.ws.rs.core.Response.Status.OK.getStatusCode())); + } +}