diff --git a/CHANGELOG.md b/CHANGELOG.md index dd303f8a8..442832d7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- Add support for managing Organizations (Keycloak 26+ only) [#1286](https://github.com/adorsys/keycloak-config-cli/issues/1286) + - Create, update, and delete organizations with domains and attributes + - Link identity providers to organizations with advanced configuration: + - Domain-based email matching for automatic provider selection + - Auto-redirect when email domain matches organization domain + - Control identity provider visibility on login page + - Support for managed properties (full/no-delete modes) + - State tracking support: Only organizations created by config-cli are deleted in full managed mode + - Automatic normalization of organization data + ## [6.4.0] - 2025-02-21 ### Added diff --git a/README.md b/README.md index a7a60d7b7..289fbce93 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,47 @@ The config files are based on the keycloak export files. You can use them to re- [moped.json](./contrib/example-config/moped.json) is a full working example file you can consider. Other examples are located in the [test resources](./src/test/resources/import-files). +### Organizations (Keycloak 26+) + +Organizations can be configured to support multi-tenancy: + +```json +{ + "realm": "my-realm", + "organizations": [ + { + "alias": "my-org", + "name": "My Organization", + "enabled": true, + "description": "Organization description", + "redirectUrl": "https://my-org.example.com", + "domains": [ + {"name": "example.com", "verified": true} + ], + "attributes": { + "contactEmail": ["admin@example.com"] + }, + "identityProviders": [ + { + "alias": "corporate-saml", + "domain": "example.com", + "redirectWhenEmailMatches": true, + "hideOnLogin": false + } + ] + } + ] +} +``` + +**Identity Provider Configuration:** +- `alias`: The identity provider alias (required) +- `domain`: Email domain for automatic provider selection +- `redirectWhenEmailMatches`: Auto-redirect users with matching email domains +- `hideOnLogin`: Hide this provider on the login page + +Note: Identity providers must be configured in the realm before linking them to organizations. + ## Variable Substitution keycloak-config-cli supports variable substitution of config files. This could be enabled by `import.var-substitution.enabled=true` (**disabled by default**). diff --git a/docker-compose.yml b/docker-compose.yml index 2423876f0..d890494f7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,7 +12,7 @@ services: - "8787:8787" command: - start-dev - - --features=admin-fine-grained-authz + - --features=admin-fine-grained-authz,organization keycloak-legacy: image: quay.io/keycloak/keycloak:${KEYCLOAK_VERSION}-legacy environment: diff --git a/docs/FEATURES.md b/docs/FEATURES.md index e49b59318..981b7d9f6 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -57,6 +57,9 @@ | Synchronize user profile | 5.4.0 | Synchronize the user profile configuration defined on the realm configuration | | Synchronize client-policies | 5.6.0 | Synchronize the client-policies (clientProfiles and clientPolicies) while updating realms | | Synchronize message bundles | 5.12.0 | Synchronize message bundles defined on the realm configuration | +| Add organizations | x.x.x | Add organizations with domains and identity provider links (Keycloak 26+ only) | +| Update organizations | x.x.x | Update organization properties, domains, and identity provider links (Keycloak 26+ only) | +| Remove organizations | x.x.x | Remove organizations while updating realms (Keycloak 26+ only) | | Normalize realm exports | x.x.x | Normalize a full realm export to be more minimal | # Specificities @@ -87,6 +90,76 @@ So if you need this, you have to configure it like : } ``` +# Organizations (Keycloak 26+ only) + +Organizations support multi-tenancy in Keycloak by grouping users and identity providers. This feature is only available in Keycloak 26 and later versions. + +## Organization Configuration + +```json +{ + "organizations": [ + { + "alias": "my-org", + "name": "My Organization", + "enabled": true, + "description": "Organization description", + "redirectUrl": "https://my-org.example.com", + "domains": [ + { + "name": "my-org.com", + "verified": true + } + ], + "attributes": { + "key": ["value1", "value2"] + }, + "identityProviders": [ + { + "alias": "saml-idp", + "domain": "my-org.com", + "redirectWhenEmailMatches": true, + "hideOnLogin": false + } + ] + } + ] +} +``` + +## Key Features: +- **Domains**: Organizations can have multiple domains with verification status +- **Identity Providers**: Link existing identity providers to organizations with configuration: + - `alias`: The identity provider alias (required) + - `domain`: Domain for email-based matching (optional) + - `redirectWhenEmailMatches`: Auto-redirect when email domain matches (optional, default: false) + - `hideOnLogin`: Hide this IdP on the login page (optional, default: false) +- **Attributes**: Support for multi-valued attributes +- **Managed Mode**: Organizations support full/no-delete managed modes +- **State Tracking**: When state tracking is enabled (default), only organizations created by config-cli are deleted in full managed mode. Manually created organizations are preserved. + +## Identity Provider Configuration +When linking identity providers to organizations, configure each provider with: + +```json +"identityProviders": [ + { + "alias": "corporate-idp", + "domain": "corp.example.com", + "redirectWhenEmailMatches": true, + "hideOnLogin": true + } +] +``` + +## Important Notes: +- Organizations must be imported AFTER identity providers since they reference IdPs by alias +- The `enabled` field defaults to `true` if not specified +- Organization aliases are normalized (lowercase, spaces replaced with hyphens) +- Domain names are normalized to lowercase +- When `redirectWhenEmailMatches` is true, users with matching email domains are automatically redirected to the IdP +- Identity provider link configurations are managed declaratively - manual changes will be overwritten on next import + # User - initial password To set an initial password that is only respect while the user is created, the userLabel must be named `initial`. diff --git a/docs/MANAGED.md b/docs/MANAGED.md index 0b3c5a283..c5c4d076f 100644 --- a/docs/MANAGED.md +++ b/docs/MANAGED.md @@ -54,11 +54,12 @@ In some cases, it is required to include some Keycloak defaults because keycloak | Authentication Flows | You have to copy the default components to your import JSON, except built-in flows.| `authentication-flow` | | Identity Providers | - | `identity-provider` | | Identity Provider Mappers | - | `identity-provider-mapper` | -| Clients | - | `client` | +| Clients | Only clients created by config-cli will be deleted (with state tracking). | `client` | | Clients Authorization Resources | The 'Default Resource' is always included. | `client-authorization-resources` | | Clients Authorization Policies | - | `client-authorization-policies` | | Clients Authorization Scopes | - | `client-authorization-scopes` | | Message Bundles | Only message bundles imported with config-cli will be managed/deleted. | `message-bundles` | +| Organizations | Only organizations created by config-cli will be deleted (with state tracking). Organizations with domains and identity provider links (Keycloak 26+ only). | `organization` | ### Disabling Deletion of Managed Entities @@ -70,3 +71,16 @@ import.managed.required-action=no-delete ### State management If `import.remote-state.enabled` is set to `true` (default value), keycloak-config-cli will purge only resources they created before by keycloak-config-cli. If `import.remote-state.enabled` is set to `false`, keycloak-config-cli will purge all existing entities if they are not defined in import json. + +#### State-Tracked Resources + +The following resources support state tracking: +- **Clients**: Only clients created by config-cli are deleted when using full managed mode +- **Organizations**: Only organizations created by config-cli are deleted when using full managed mode +- **Roles**: Realm and client roles created by config-cli +- **Components**: Components and sub-components created by config-cli +- **Required Actions**: Required actions created by config-cli +- **Client Authorization Resources**: Resources created by config-cli +- **Message Bundles**: Message bundles created by config-cli + +This means that manually created resources (through Keycloak Admin UI or other means) will be preserved when state tracking is enabled. diff --git a/src/main/java/de/adorsys/keycloak/config/model/OrganizationDomainRepresentation.java b/src/main/java/de/adorsys/keycloak/config/model/OrganizationDomainRepresentation.java new file mode 100644 index 000000000..d1283546c --- /dev/null +++ b/src/main/java/de/adorsys/keycloak/config/model/OrganizationDomainRepresentation.java @@ -0,0 +1,64 @@ +/*- + * ---license-start + * keycloak-config-cli + * --- + * Copyright (C) 2017 - 2025 adorsys GmbH & Co. KG @ https://adorsys.com + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ---license-end + */ + +package de.adorsys.keycloak.config.model; + +import java.util.Objects; + +public class OrganizationDomainRepresentation { + private String name; + private Boolean verified; + + public OrganizationDomainRepresentation() { + } + + public OrganizationDomainRepresentation(String name) { + this.name = name; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Boolean isVerified() { + return verified; + } + + public void setVerified(Boolean verified) { + this.verified = verified; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + OrganizationDomainRepresentation that = (OrganizationDomainRepresentation) o; + return Objects.equals(name, that.name); + } + + @Override + public int hashCode() { + return Objects.hash(name); + } +} \ No newline at end of file diff --git a/src/main/java/de/adorsys/keycloak/config/model/OrganizationIdentityProviderConfig.java b/src/main/java/de/adorsys/keycloak/config/model/OrganizationIdentityProviderConfig.java new file mode 100644 index 000000000..0e8ef0e87 --- /dev/null +++ b/src/main/java/de/adorsys/keycloak/config/model/OrganizationIdentityProviderConfig.java @@ -0,0 +1,108 @@ +/*- + * ---license-start + * keycloak-config-cli + * --- + * Copyright (C) 2017 - 2025 adorsys GmbH & Co. KG @ https://adorsys.com + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ---license-end + */ + +package de.adorsys.keycloak.config.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.Objects; + +/** + * Configuration for linking an identity provider to an organization. + * Supports domain-based email matching and visibility settings. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class OrganizationIdentityProviderConfig { + + @JsonProperty("alias") + private String alias; + + @JsonProperty("domain") + private String domain; + + @JsonProperty("redirectWhenEmailMatches") + private Boolean redirectWhenEmailMatches; + + @JsonProperty("hideOnLogin") + private Boolean hideOnLogin; + + public OrganizationIdentityProviderConfig() { + } + + public OrganizationIdentityProviderConfig(String alias) { + this.alias = alias; + } + + public String getAlias() { + return alias; + } + + public void setAlias(String alias) { + this.alias = alias; + } + + public String getDomain() { + return domain; + } + + public void setDomain(String domain) { + this.domain = domain; + } + + public Boolean getRedirectWhenEmailMatches() { + return redirectWhenEmailMatches; + } + + public void setRedirectWhenEmailMatches(Boolean redirectWhenEmailMatches) { + this.redirectWhenEmailMatches = redirectWhenEmailMatches; + } + + public Boolean getHideOnLogin() { + return hideOnLogin; + } + + public void setHideOnLogin(Boolean hideOnLogin) { + this.hideOnLogin = hideOnLogin; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + OrganizationIdentityProviderConfig that = (OrganizationIdentityProviderConfig) o; + return Objects.equals(alias, that.alias); + } + + @Override + public int hashCode() { + return Objects.hash(alias); + } + + @Override + public String toString() { + return "OrganizationIdentityProviderConfig{" + + "alias='" + alias + '\'' + + ", domain='" + domain + '\'' + + ", redirectWhenEmailMatches=" + redirectWhenEmailMatches + + ", hideOnLogin=" + hideOnLogin + + '}'; + } +} \ No newline at end of file diff --git a/src/main/java/de/adorsys/keycloak/config/model/OrganizationRepresentation.java b/src/main/java/de/adorsys/keycloak/config/model/OrganizationRepresentation.java new file mode 100644 index 000000000..a7453090e --- /dev/null +++ b/src/main/java/de/adorsys/keycloak/config/model/OrganizationRepresentation.java @@ -0,0 +1,187 @@ +/*- + * ---license-start + * keycloak-config-cli + * --- + * Copyright (C) 2017 - 2025 adorsys GmbH & Co. KG @ https://adorsys.com + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ---license-end + */ + +package de.adorsys.keycloak.config.model; + +import com.fasterxml.jackson.annotation.JsonIgnore; + +import java.util.*; + +/** + * Represents a Keycloak Organization entity for multi-tenancy support. + * Organizations group users and identity providers under a common domain. + * Available in Keycloak 26+. + */ +public class OrganizationRepresentation { + private String id; + private String name; + private String alias; + private Boolean enabled; + private String description; + private String redirectUrl; + private Map> attributes; + private Set domains; + private List identityProviders; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getAlias() { + return alias; + } + + public void setAlias(String alias) { + this.alias = alias; + } + + public Boolean getEnabled() { + return enabled; + } + + public void setEnabled(Boolean enabled) { + this.enabled = enabled; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getRedirectUrl() { + return redirectUrl; + } + + public void setRedirectUrl(String redirectUrl) { + this.redirectUrl = redirectUrl; + } + + public Map> getAttributes() { + return attributes; + } + + public void setAttributes(Map> attributes) { + this.attributes = attributes; + } + + public void singleAttribute(String name, String value) { + if (attributes == null) { + attributes = new HashMap<>(); + } + attributes.put(name, Collections.singletonList(value)); + } + + public Set getDomains() { + return domains; + } + + public void setDomains(Set domains) { + this.domains = domains; + } + + public OrganizationDomainRepresentation getDomain(String name) { + if (domains == null || name == null) { + return null; + } + return domains.stream() + .filter(domain -> domain != null && name.equals(domain.getName())) + .findFirst() + .orElse(null); + } + + public void addDomain(OrganizationDomainRepresentation domain) { + if (domain == null) { + return; + } + if (domains == null) { + domains = new HashSet<>(); + } + domains.add(domain); + } + + public void removeDomain(OrganizationDomainRepresentation domain) { + if (domains != null) { + domains.remove(domain); + } + } + + public List getIdentityProviders() { + return identityProviders; + } + + public void setIdentityProviders(List identityProviders) { + this.identityProviders = identityProviders; + } + + @JsonIgnore + public List getIdentityProviderAliases() { + if (identityProviders == null) { + return null; + } + return identityProviders.stream() + .map(OrganizationIdentityProviderConfig::getAlias) + .toList(); + } + + public void addIdentityProvider(OrganizationIdentityProviderConfig identityProviderConfig) { + if (identityProviders == null) { + identityProviders = new ArrayList<>(); + } + // Check if already exists based on alias + boolean exists = identityProviders.stream() + .anyMatch(config -> Objects.equals(config.getAlias(), identityProviderConfig.getAlias())); + if (!exists) { + identityProviders.add(identityProviderConfig); + } + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + OrganizationRepresentation that = (OrganizationRepresentation) o; + // Use ID if available, otherwise fall back to alias + if (id != null && that.id != null) { + return Objects.equals(id, that.id); + } + return Objects.equals(alias, that.alias); + } + + @Override + public int hashCode() { + // Use ID if available, otherwise fall back to alias + return Objects.hash(id != null ? id : alias); + } +} \ No newline at end of file diff --git a/src/main/java/de/adorsys/keycloak/config/model/RealmImport.java b/src/main/java/de/adorsys/keycloak/config/model/RealmImport.java index a6f86cc19..3414c6e7c 100644 --- a/src/main/java/de/adorsys/keycloak/config/model/RealmImport.java +++ b/src/main/java/de/adorsys/keycloak/config/model/RealmImport.java @@ -2,7 +2,7 @@ * ---license-start * keycloak-config-cli * --- - * Copyright (C) 2017 - 2021 adorsys GmbH & Co. KG @ https://adorsys.com + * Copyright (C) 2017 - 2025 adorsys GmbH & Co. KG @ https://adorsys.com * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -41,6 +41,8 @@ public class RealmImport extends RealmRepresentation { private Map> messageBundles; + private List organizationList; + private String checksum; private String source; @@ -78,6 +80,16 @@ public UPConfig getUserProfile() { return userProfile; } + public List getOrganizationList() { + return organizationList; + } + + @SuppressWarnings("unused") + @JsonSetter("organizations") + public void setOrganizationList(List organizationList) { + this.organizationList = organizationList; + } + @JsonIgnore public String getChecksum() { return checksum; @@ -97,4 +109,4 @@ public String getSource() { public void setSource(String source) { this.source = source; } -} +} \ No newline at end of file diff --git a/src/main/java/de/adorsys/keycloak/config/properties/ImportConfigProperties.java b/src/main/java/de/adorsys/keycloak/config/properties/ImportConfigProperties.java index 3e84838ba..229c5fbc0 100644 --- a/src/main/java/de/adorsys/keycloak/config/properties/ImportConfigProperties.java +++ b/src/main/java/de/adorsys/keycloak/config/properties/ImportConfigProperties.java @@ -163,6 +163,9 @@ public static class ImportManagedProperties { @NotNull private final ImportManagedPropertiesValues messageBundles; + @NotNull + private final ImportManagedPropertiesValues organization; + public ImportManagedProperties(@DefaultValue("FULL") ImportManagedPropertiesValues requiredAction, @DefaultValue("FULL") ImportManagedPropertiesValues group, @DefaultValue("FULL") ImportManagedPropertiesValues clientScope, @@ -178,7 +181,8 @@ public ImportManagedProperties(@DefaultValue("FULL") ImportManagedPropertiesValu @DefaultValue("FULL") ImportManagedPropertiesValues clientAuthorizationResources, @DefaultValue("FULL") ImportManagedPropertiesValues clientAuthorizationPolicies, @DefaultValue("FULL") ImportManagedPropertiesValues clientAuthorizationScopes, - @DefaultValue("FULL") ImportManagedPropertiesValues messageBundles) { + @DefaultValue("FULL") ImportManagedPropertiesValues messageBundles, + @DefaultValue("FULL") ImportManagedPropertiesValues organization) { this.requiredAction = requiredAction; this.group = group; this.clientScope = clientScope; @@ -195,6 +199,7 @@ public ImportManagedProperties(@DefaultValue("FULL") ImportManagedPropertiesValu this.clientAuthorizationPolicies = clientAuthorizationPolicies; this.clientAuthorizationScopes = clientAuthorizationScopes; this.messageBundles = messageBundles; + this.organization = organization; } public ImportManagedPropertiesValues getRequiredAction() { @@ -261,6 +266,10 @@ public ImportManagedPropertiesValues getMessageBundles() { return messageBundles; } + public ImportManagedPropertiesValues getOrganization() { + return organization; + } + public enum ImportManagedPropertiesValues { FULL, NO_DELETE } diff --git a/src/main/java/de/adorsys/keycloak/config/repository/OrganizationRepository.java b/src/main/java/de/adorsys/keycloak/config/repository/OrganizationRepository.java new file mode 100644 index 000000000..cbce30b3d --- /dev/null +++ b/src/main/java/de/adorsys/keycloak/config/repository/OrganizationRepository.java @@ -0,0 +1,260 @@ +/*- + * ---license-start + * keycloak-config-cli + * --- + * Copyright (C) 2017 - 2025 adorsys GmbH & Co. KG @ https://adorsys.com + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ---license-end + */ + +package de.adorsys.keycloak.config.repository; + +import de.adorsys.keycloak.config.exception.KeycloakRepositoryException; +import de.adorsys.keycloak.config.model.OrganizationIdentityProviderConfig; +import de.adorsys.keycloak.config.model.OrganizationRepresentation; +import de.adorsys.keycloak.config.provider.KeycloakProvider; +import de.adorsys.keycloak.config.resource.OrganizationApi; +import de.adorsys.keycloak.config.resource.OrganizationIdentityProviderApi; +import org.keycloak.representations.idm.IdentityProviderRepresentation; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Optional; + +import jakarta.ws.rs.ClientErrorException; +import jakarta.ws.rs.NotFoundException; +import jakarta.ws.rs.core.Response; + +@Service +@ConditionalOnBean(KeycloakProvider.class) +public class OrganizationRepository { + private static final Logger logger = LoggerFactory.getLogger(OrganizationRepository.class); + private static final int HTTP_CREATED = 201; + private static final int HTTP_NO_CONTENT = 204; + private static final String KC_ORG_DOMAIN = "kc.org.domain"; + private static final String KC_ORG_REDIRECT_EMAIL_MATCHES = "kc.org.broker.redirect.mode.email-matches"; + + private final KeycloakProvider keycloakProvider; + private final IdentityProviderRepository identityProviderRepository; + + @Autowired + public OrganizationRepository(KeycloakProvider keycloakProvider, IdentityProviderRepository identityProviderRepository) { + this.keycloakProvider = keycloakProvider; + this.identityProviderRepository = identityProviderRepository; + } + + public void create(String realm, OrganizationRepresentation organization) { + if (!StringUtils.hasText(realm)) { + throw new IllegalArgumentException("Realm cannot be null or empty"); + } + if (organization == null) { + throw new IllegalArgumentException("Organization cannot be null"); + } + if (!StringUtils.hasText(organization.getAlias())) { + throw new IllegalArgumentException("Organization alias cannot be null or empty"); + } + + logger.debug("Creating organization '{}' in realm '{}'", organization.getAlias(), realm); + OrganizationApi api = keycloakProvider.getCustomApiProxy(OrganizationApi.class); + + try (Response response = api.create(realm, organization)) { + if (response.getStatus() != HTTP_CREATED) { + throw new KeycloakRepositoryException( + String.format("Failed to create organization '%s' in realm '%s'. Status: %d. " + + "Ensure the realm has organizationsEnabled=true and the organization alias is unique.", + organization.getAlias(), realm, response.getStatus())); + } + + if (response.getLocation() != null) { + String location = response.getLocation().toString(); + String id = location.substring(location.lastIndexOf('/') + 1); + organization.setId(id); + } + } + } + + public void update(String realm, OrganizationRepresentation organization) { + if (!StringUtils.hasText(realm)) { + throw new IllegalArgumentException("Realm cannot be null or empty"); + } + if (organization == null) { + throw new IllegalArgumentException("Organization cannot be null"); + } + if (!StringUtils.hasText(organization.getId())) { + throw new IllegalArgumentException("Organization ID cannot be null or empty"); + } + + OrganizationApi api = keycloakProvider.getCustomApiProxy(OrganizationApi.class); + + try { + api.update(realm, organization.getId(), organization); + } catch (NotFoundException e) { + throw new KeycloakRepositoryException( + String.format("Cannot update organization '%s' in realm '%s': Not found", + organization.getAlias(), realm), e); + } + } + + public void delete(String realm, String organizationId) { + OrganizationApi api = keycloakProvider.getCustomApiProxy(OrganizationApi.class); + + try { + api.delete(realm, organizationId); + } catch (NotFoundException e) { + throw new KeycloakRepositoryException( + String.format("Cannot delete organization with id '%s' in realm '%s': Not found", + organizationId, realm), e); + } + } + + public OrganizationRepresentation get(String realm, String organizationId) { + OrganizationApi api = keycloakProvider.getCustomApiProxy(OrganizationApi.class); + + try { + return api.get(realm, organizationId); + } catch (NotFoundException e) { + throw new KeycloakRepositoryException( + String.format("Cannot get organization with id '%s' in realm '%s': Not found", + organizationId, realm), e); + } + } + + public List getAll(String realm) { + OrganizationApi api = keycloakProvider.getCustomApiProxy(OrganizationApi.class); + List organizations = api.search(realm, null, null, null, null, null, false); + return organizations != null ? organizations : new ArrayList<>(); + } + + public Optional search(String realm, String searchValue) { + OrganizationApi api = keycloakProvider.getCustomApiProxy(OrganizationApi.class); + + // First try exact match + List exactResults = api.search(realm, searchValue, null, true, null, null, false); + if (!exactResults.isEmpty()) { + return Optional.of(exactResults.get(0)); + } + + // If no exact match, try partial match + List partialResults = api.search(realm, searchValue, null, false, null, null, false); + return partialResults.stream() + .filter(org -> searchValue.equals(org.getName()) || searchValue.equals(org.getAlias())) + .findFirst(); + } + + public Optional getByAlias(String realm, String alias) { + return search(realm, alias); + } + + // Identity Provider management methods + + public void updateIdentityProviderConfiguration(String realm, OrganizationIdentityProviderConfig config) { + // Get the current identity provider + Optional maybeIdp = identityProviderRepository.search(realm, config.getAlias()); + if (maybeIdp.isEmpty()) { + throw new KeycloakRepositoryException( + String.format("Identity provider '%s' not found in realm '%s'", config.getAlias(), realm)); + } + + IdentityProviderRepresentation idp = maybeIdp.get(); + + // Update the identity provider configuration with organization-specific settings + if (idp.getConfig() == null) { + idp.setConfig(new HashMap<>()); + } + + // Update organization-specific configuration + // Set or remove domain + if (config.getDomain() != null) { + idp.getConfig().put(KC_ORG_DOMAIN, config.getDomain()); + } else { + idp.getConfig().remove(KC_ORG_DOMAIN); + } + + // Set or remove redirect email matches + if (config.getRedirectWhenEmailMatches() != null) { + idp.getConfig().put(KC_ORG_REDIRECT_EMAIL_MATCHES, config.getRedirectWhenEmailMatches().toString()); + } else { + idp.getConfig().remove(KC_ORG_REDIRECT_EMAIL_MATCHES); + } + + // Update hide on login + if (config.getHideOnLogin() != null) { + // Store in config map for compatibility with older Keycloak versions + idp.getConfig().put("hideOnLoginPage", config.getHideOnLogin().toString()); + } else { + idp.getConfig().remove("hideOnLoginPage"); + } + + // Update the identity provider with the new configuration + identityProviderRepository.update(realm, idp); + } + + public void linkIdentityProvider(String realm, String organizationId, OrganizationIdentityProviderConfig config) { + OrganizationIdentityProviderApi api = keycloakProvider.getCustomApiProxy(OrganizationIdentityProviderApi.class); + + // Update the identity provider configuration + updateIdentityProviderConfiguration(realm, config); + + // Now link it to the organization + try (Response response = api.addIdentityProvider(realm, organizationId, config.getAlias())) { + // API may return either 201 (created) or 204 (no content) on success + if (response.getStatus() != HTTP_NO_CONTENT && response.getStatus() != HTTP_CREATED) { + throw new KeycloakRepositoryException( + String.format("Failed to link identity provider '%s' to organization '%s' in realm '%s'. Status: %d", + config.getAlias(), organizationId, realm, response.getStatus())); + } + } catch (ClientErrorException e) { + throw new KeycloakRepositoryException( + String.format("Cannot link identity provider '%s' to organization '%s' in realm '%s': %s", + config.getAlias(), organizationId, realm, e.getMessage()), e); + } + } + + public void unlinkIdentityProvider(String realm, String organizationId, String identityProviderAlias) { + OrganizationIdentityProviderApi api = keycloakProvider.getCustomApiProxy(OrganizationIdentityProviderApi.class); + + try { + api.removeIdentityProvider(realm, organizationId, identityProviderAlias); + } catch (NotFoundException e) { + // Ignore if already unlinked - this is expected behavior when the identity provider + // is not linked to the organization or has been previously removed + logger.debug("Identity provider '{}' was not linked to organization '{}' in realm '{}' - ignoring unlink request", + identityProviderAlias, organizationId, realm); + } catch (ClientErrorException e) { + throw new KeycloakRepositoryException( + String.format("Cannot unlink identity provider '%s' from organization '%s' in realm '%s': %s", + identityProviderAlias, organizationId, realm, e.getMessage()), e); + } + } + + public List getLinkedIdentityProviders(String realm, String organizationId) { + OrganizationIdentityProviderApi api = keycloakProvider.getCustomApiProxy(OrganizationIdentityProviderApi.class); + + try { + List providers = api.getIdentityProviders(realm, organizationId); + return providers != null ? providers : new ArrayList<>(); + } catch (NotFoundException e) { + throw new KeycloakRepositoryException( + String.format("Cannot get identity providers for organization '%s' in realm '%s': Organization not found", + organizationId, realm), e); + } + } +} \ No newline at end of file diff --git a/src/main/java/de/adorsys/keycloak/config/resource/OrganizationApi.java b/src/main/java/de/adorsys/keycloak/config/resource/OrganizationApi.java new file mode 100644 index 000000000..9627e4a4c --- /dev/null +++ b/src/main/java/de/adorsys/keycloak/config/resource/OrganizationApi.java @@ -0,0 +1,72 @@ +/*- + * ---license-start + * keycloak-config-cli + * --- + * Copyright (C) 2017 - 2025 adorsys GmbH & Co. KG @ https://adorsys.com + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ---license-end + */ + +package de.adorsys.keycloak.config.resource; + +import de.adorsys.keycloak.config.model.OrganizationRepresentation; + +import java.util.List; + +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +/** + * Custom API interface for Keycloak Organizations management. + * This is needed because the Keycloak admin client 26.0.4 doesn't include Organizations API. + */ +@Path("/admin/realms/{realm}/organizations") +public interface OrganizationApi { + + @POST + @Consumes(MediaType.APPLICATION_JSON) + Response create(@PathParam("realm") String realm, OrganizationRepresentation organization); + + @GET + @Produces(MediaType.APPLICATION_JSON) + List search(@PathParam("realm") String realm, + @QueryParam("search") String search, + @QueryParam("searchQuery") String searchQuery, + @QueryParam("exact") Boolean exact, + @QueryParam("first") Integer first, + @QueryParam("max") Integer max, + @QueryParam("briefRepresentation") Boolean briefRepresentation); + + @GET + @Path("/count") + @Produces(MediaType.APPLICATION_JSON) + Integer count(@PathParam("realm") String realm, + @QueryParam("search") String search, + @QueryParam("searchQuery") String searchQuery); + + @GET + @Path("/{id}") + @Produces(MediaType.APPLICATION_JSON) + OrganizationRepresentation get(@PathParam("realm") String realm, @PathParam("id") String id); + + @PUT + @Path("/{id}") + @Consumes(MediaType.APPLICATION_JSON) + void update(@PathParam("realm") String realm, @PathParam("id") String id, OrganizationRepresentation organization); + + @DELETE + @Path("/{id}") + void delete(@PathParam("realm") String realm, @PathParam("id") String id); +} \ No newline at end of file diff --git a/src/main/java/de/adorsys/keycloak/config/resource/OrganizationIdentityProviderApi.java b/src/main/java/de/adorsys/keycloak/config/resource/OrganizationIdentityProviderApi.java new file mode 100644 index 000000000..7f432bcf6 --- /dev/null +++ b/src/main/java/de/adorsys/keycloak/config/resource/OrganizationIdentityProviderApi.java @@ -0,0 +1,61 @@ +/*- + * ---license-start + * keycloak-config-cli + * --- + * Copyright (C) 2017 - 2025 adorsys GmbH & Co. KG @ https://adorsys.com + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ---license-end + */ + +package de.adorsys.keycloak.config.resource; + +import org.keycloak.representations.idm.IdentityProviderRepresentation; + +import java.util.List; + +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +/** + * Custom API interface for managing identity providers within organizations. + * This is needed because the Keycloak admin client 26.0.4 doesn't include Organizations API. + */ +@Path("/admin/realms/{realm}/organizations/{orgId}/identity-providers") +public interface OrganizationIdentityProviderApi { + + @GET + @Produces(MediaType.APPLICATION_JSON) + List getIdentityProviders(@PathParam("realm") String realm, + @PathParam("orgId") String orgId); + + @POST + @Consumes(MediaType.APPLICATION_JSON) + Response addIdentityProvider(@PathParam("realm") String realm, + @PathParam("orgId") String orgId, + String identityProviderAlias); + + @GET + @Path("/{alias}") + @Produces(MediaType.APPLICATION_JSON) + IdentityProviderRepresentation getIdentityProvider(@PathParam("realm") String realm, + @PathParam("orgId") String orgId, + @PathParam("alias") String alias); + + @DELETE + @Path("/{alias}") + void removeIdentityProvider(@PathParam("realm") String realm, + @PathParam("orgId") String orgId, + @PathParam("alias") String alias); +} \ No newline at end of file diff --git a/src/main/java/de/adorsys/keycloak/config/service/OrganizationImportService.java b/src/main/java/de/adorsys/keycloak/config/service/OrganizationImportService.java new file mode 100644 index 000000000..76d3824d6 --- /dev/null +++ b/src/main/java/de/adorsys/keycloak/config/service/OrganizationImportService.java @@ -0,0 +1,261 @@ +/*- + * ---license-start + * keycloak-config-cli + * --- + * Copyright (C) 2017 - 2025 adorsys GmbH & Co. KG @ https://adorsys.com + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ---license-end + */ + +package de.adorsys.keycloak.config.service; + +import de.adorsys.keycloak.config.exception.ImportProcessingException; +import de.adorsys.keycloak.config.model.OrganizationIdentityProviderConfig; +import de.adorsys.keycloak.config.model.OrganizationRepresentation; +import de.adorsys.keycloak.config.model.RealmImport; +import de.adorsys.keycloak.config.properties.ImportConfigProperties; +import de.adorsys.keycloak.config.provider.KeycloakProvider; +import de.adorsys.keycloak.config.repository.IdentityProviderRepository; +import de.adorsys.keycloak.config.repository.OrganizationRepository; +import de.adorsys.keycloak.config.service.normalize.OrganizationNormalizationService; +import de.adorsys.keycloak.config.service.state.StateService; +import de.adorsys.keycloak.config.util.CloneUtil; +import de.adorsys.keycloak.config.util.VersionUtil; +import org.keycloak.representations.idm.IdentityProviderRepresentation; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.util.*; +import java.util.stream.Collectors; + +import static de.adorsys.keycloak.config.properties.ImportConfigProperties.ImportManagedProperties.ImportManagedPropertiesValues; + +/** + * Service responsible for importing Keycloak Organizations. + * Handles creation, update, and deletion of organizations based on managed mode. + * Organizations must be imported after identity providers due to dependencies. + */ +@Service +@ConditionalOnProperty(prefix = "run", name = "operation", havingValue = "IMPORT", matchIfMissing = true) +@ConditionalOnBean(KeycloakProvider.class) +public class OrganizationImportService { + private static final Logger logger = LoggerFactory.getLogger(OrganizationImportService.class); + + private final OrganizationRepository organizationRepository; + private final IdentityProviderRepository identityProviderRepository; + private final ImportConfigProperties importConfigProperties; + private final OrganizationNormalizationService organizationNormalizationService; + private final KeycloakProvider keycloakProvider; + private final StateService stateService; + + @Autowired + public OrganizationImportService( + OrganizationRepository organizationRepository, + IdentityProviderRepository identityProviderRepository, + ImportConfigProperties importConfigProperties, + OrganizationNormalizationService organizationNormalizationService, + KeycloakProvider keycloakProvider, + StateService stateService + ) { + this.organizationRepository = organizationRepository; + this.identityProviderRepository = identityProviderRepository; + this.importConfigProperties = importConfigProperties; + this.organizationNormalizationService = organizationNormalizationService; + this.keycloakProvider = keycloakProvider; + this.stateService = stateService; + } + + public void doImport(RealmImport realmImport) { + List organizations = realmImport.getOrganizationList(); + if (organizations == null) { + return; + } + + // Organizations are only supported in Keycloak 26+ + if (!VersionUtil.ge(keycloakProvider.getKeycloakVersion(), "26")) { + if (!organizations.isEmpty()) { + logger.warn("Organizations are configured but not supported in Keycloak version {}. Skipping organizations import.", + keycloakProvider.getKeycloakVersion()); + } + return; + } + + String realmName = realmImport.getRealm(); + + // Normalize organizations before processing + organizations = organizationNormalizationService.normalizeOrganizations(organizations); + + // First phase: Create/update organizations (and delete if managed mode is FULL) + manageOrganizations(realmName, organizations); + + // Second phase: Link identity providers + // This is done separately to ensure all organizations exist before linking + for (OrganizationRepresentation organization : organizations) { + updateIdentityProviderLinks(realmName, organization); + } + } + + private void manageOrganizations(String realmName, List organizations) { + List existingOrganizations = organizationRepository.getAll(realmName); + + if (importConfigProperties.getManaged().getOrganization() == ImportManagedPropertiesValues.FULL) { + deleteOrganizationsMissingInImport(realmName, organizations, existingOrganizations); + } + + for (OrganizationRepresentation organization : organizations) { + createOrUpdateOrganization(realmName, organization); + } + } + + private void deleteOrganizationsMissingInImport( + String realmName, + List importedOrganizations, + List existingOrganizations + ) { + Set importedAliases = importedOrganizations.stream() + .map(OrganizationRepresentation::getAlias) + .collect(Collectors.toSet()); + + boolean isState = importConfigProperties.getRemoteState().isEnabled(); + final List stateOrganizations = stateService.getOrganizations(); + + for (OrganizationRepresentation existingOrg : existingOrganizations) { + if (!importedAliases.contains(existingOrg.getAlias()) + && (!isState || stateOrganizations.contains(existingOrg.getAlias()))) { + logger.debug("Delete organization '{}' in realm '{}'", existingOrg.getAlias(), realmName); + organizationRepository.delete(realmName, existingOrg.getId()); + } + } + } + + private void createOrUpdateOrganization(String realmName, OrganizationRepresentation organization) { + String organizationAlias = organization.getAlias(); + + if (!StringUtils.hasText(organizationAlias)) { + throw new ImportProcessingException("Organization alias cannot be null or empty"); + } + + Optional maybeOrganization = organizationRepository.getByAlias(realmName, organizationAlias); + + if (maybeOrganization.isPresent()) { + updateOrganizationIfNeeded(realmName, organization, maybeOrganization.get()); + } else { + logger.debug("Create organization '{}' in realm '{}'", organizationAlias, realmName); + + // Don't send identity providers during creation + OrganizationRepresentation orgToCreate = CloneUtil.deepClone(organization); + orgToCreate.setIdentityProviders(null); + + organizationRepository.create(realmName, orgToCreate); + + // Update the original organization with the generated ID + organization.setId(orgToCreate.getId()); + } + } + + private void updateOrganizationIfNeeded(String realmName, OrganizationRepresentation importedOrg, + OrganizationRepresentation existingOrg) { + // Set the ID from existing organization + importedOrg.setId(existingOrg.getId()); + + // Create a copy without identity providers for comparison + OrganizationRepresentation importedWithoutIdps = CloneUtil.deepClone(importedOrg); + importedWithoutIdps.setIdentityProviders(null); + + OrganizationRepresentation existingWithoutIdps = CloneUtil.deepClone(existingOrg); + existingWithoutIdps.setIdentityProviders(null); + + String organizationAlias = importedOrg.getAlias(); + + if (!CloneUtil.deepEquals(importedWithoutIdps, existingWithoutIdps)) { + logger.debug("Update organization '{}' in realm '{}'", organizationAlias, realmName); + organizationRepository.update(realmName, importedWithoutIdps); + } else { + logger.debug("No need to update organization '{}' in realm '{}'", organizationAlias, realmName); + } + } + + private void updateIdentityProviderLinks(String realmName, OrganizationRepresentation organization) { + if (organization.getId() == null) { + // If we don't have an ID, try to get it from the repository + Optional maybeOrg = organizationRepository.getByAlias(realmName, organization.getAlias()); + if (maybeOrg.isPresent()) { + organization.setId(maybeOrg.get().getId()); + } else { + logger.warn("Cannot update identity provider links for organization '{}': Organization not found", + organization.getAlias()); + return; + } + } + + String organizationId = organization.getId(); + List desiredIdpConfigs = organization.getIdentityProviders(); + + if (desiredIdpConfigs == null) { + desiredIdpConfigs = Collections.emptyList(); + } + + // Get current linked IdPs + List currentIdps = organizationRepository.getLinkedIdentityProviders(realmName, organizationId); + Set currentIdpAliases = currentIdps.stream() + .map(IdentityProviderRepresentation::getAlias) + .collect(Collectors.toSet()); + + Set desiredIdpAliasesSet = desiredIdpConfigs.stream() + .map(OrganizationIdentityProviderConfig::getAlias) + .collect(Collectors.toSet()); + + // Link new IdPs and update existing ones + for (OrganizationIdentityProviderConfig idpConfig : desiredIdpConfigs) { + validateIdentityProviderExists(realmName, idpConfig.getAlias()); + + if (!currentIdpAliases.contains(idpConfig.getAlias())) { + // New link + logger.debug("Link identity provider '{}' to organization '{}' in realm '{}'", + idpConfig.getAlias(), organization.getAlias(), realmName); + organizationRepository.linkIdentityProvider(realmName, organizationId, idpConfig); + } else { + // Existing link - update configuration + logger.debug("Update identity provider '{}' configuration for organization '{}' in realm '{}'", + idpConfig.getAlias(), organization.getAlias(), realmName); + organizationRepository.updateIdentityProviderConfiguration(realmName, idpConfig); + } + } + + // Unlink removed IdPs (only in FULL managed mode) + if (importConfigProperties.getManaged().getOrganization() == ImportManagedPropertiesValues.FULL) { + for (String currentIdpAlias : currentIdpAliases) { + if (!desiredIdpAliasesSet.contains(currentIdpAlias)) { + logger.debug("Unlink identity provider '{}' from organization '{}' in realm '{}'", + currentIdpAlias, organization.getAlias(), realmName); + organizationRepository.unlinkIdentityProvider(realmName, organizationId, currentIdpAlias); + } + } + } + } + + private void validateIdentityProviderExists(String realmName, String idpAlias) { + Optional maybeIdp = identityProviderRepository.search(realmName, idpAlias); + if (maybeIdp.isEmpty()) { + throw new ImportProcessingException( + String.format("Identity provider '%s' does not exist in realm '%s'", + idpAlias, realmName)); + } + } +} \ No newline at end of file diff --git a/src/main/java/de/adorsys/keycloak/config/service/RealmImportService.java b/src/main/java/de/adorsys/keycloak/config/service/RealmImportService.java index 14c31518c..5550cbf40 100644 --- a/src/main/java/de/adorsys/keycloak/config/service/RealmImportService.java +++ b/src/main/java/de/adorsys/keycloak/config/service/RealmImportService.java @@ -61,6 +61,7 @@ public class RealmImportService { "defaultOptionalClientScopes", "clientProfiles", "clientPolicies", + "organizations", }; private static final Logger logger = LoggerFactory.getLogger(RealmImportService.class); @@ -87,6 +88,7 @@ public class RealmImportService { private final ClientScopeMappingImportService clientScopeMappingImportService; private final IdentityProviderImportService identityProviderImportService; private final MessageBundleImportService messageBundleImportService; + private final OrganizationImportService organizationImportService; private final ImportConfigProperties importProperties; @@ -115,6 +117,7 @@ public RealmImportService( ClientScopeMappingImportService clientScopeMappingImportService, IdentityProviderImportService identityProviderImportService, MessageBundleImportService messageBundleImportService, + OrganizationImportService organizationImportService, OtpPolicyImportService otpPolicyImportService, ChecksumService checksumService, StateService stateService) { @@ -138,6 +141,7 @@ public RealmImportService( this.clientScopeMappingImportService = clientScopeMappingImportService; this.identityProviderImportService = identityProviderImportService; this.messageBundleImportService = messageBundleImportService; + this.organizationImportService = organizationImportService; this.otpPolicyImportService = otpPolicyImportService; this.checksumService = checksumService; this.stateService = stateService; @@ -234,6 +238,7 @@ private void configureRealm(RealmImport realmImport, RealmRepresentation existin clientImportService.doImportDependencies(realmImport); clientScopeImportService.updateDefaultClientScopes(realmImport, existingRealm); identityProviderImportService.doImport(realmImport); + organizationImportService.doImport(realmImport); clientAuthorizationImportService.doImport(realmImport); scopeMappingImportService.doImport(realmImport); clientScopeMappingImportService.doImport(realmImport); diff --git a/src/main/java/de/adorsys/keycloak/config/service/normalize/OrganizationNormalizationService.java b/src/main/java/de/adorsys/keycloak/config/service/normalize/OrganizationNormalizationService.java new file mode 100644 index 000000000..4ffab868e --- /dev/null +++ b/src/main/java/de/adorsys/keycloak/config/service/normalize/OrganizationNormalizationService.java @@ -0,0 +1,138 @@ +/*- + * ---license-start + * keycloak-config-cli + * --- + * Copyright (C) 2017 - 2025 adorsys GmbH & Co. KG @ https://adorsys.com + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ---license-end + */ + +package de.adorsys.keycloak.config.service.normalize; + +import de.adorsys.keycloak.config.model.OrganizationIdentityProviderConfig; +import de.adorsys.keycloak.config.model.OrganizationRepresentation; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.util.*; + +@Service +@ConditionalOnProperty(prefix = "run", name = "operation", havingValue = "NORMALIZE", matchIfMissing = true) +public class OrganizationNormalizationService { + private static final Logger logger = LoggerFactory.getLogger(OrganizationNormalizationService.class); + + private static final String WHITESPACE_PATTERN = "\\s+"; + private static final String INVALID_ALIAS_CHARS_PATTERN = "[^a-z0-9-]"; + private static final String HYPHEN_REPLACEMENT = "-"; + + public List normalizeOrganizations(List organizations) { + if (organizations == null) { + return null; + } + + for (OrganizationRepresentation organization : organizations) { + normalizeOrganization(organization); + } + + return organizations; + } + + private void normalizeOrganization(OrganizationRepresentation organization) { + // Set default enabled value if not specified + if (organization.getEnabled() == null) { + organization.setEnabled(true); + } + + // Normalize alias - convert to lowercase and replace spaces with hyphens + if (organization.getAlias() != null) { + String normalizedAlias = organization.getAlias() + .toLowerCase() + .replaceAll(WHITESPACE_PATTERN, HYPHEN_REPLACEMENT) + .replaceAll(INVALID_ALIAS_CHARS_PATTERN, ""); + organization.setAlias(normalizedAlias); + } + + // Normalize attributes - ensure all values are lists + if (organization.getAttributes() != null) { + Map> normalizedAttributes = new HashMap<>(); + + for (Map.Entry> entry : organization.getAttributes().entrySet()) { + String key = entry.getKey(); + List values = entry.getValue(); + + if (values != null && !values.isEmpty()) { + // Remove null and empty values + List cleanedValues = new ArrayList<>(); + for (String value : values) { + if (StringUtils.hasText(value)) { + cleanedValues.add(value.trim()); + } + } + + if (!cleanedValues.isEmpty()) { + normalizedAttributes.put(key, cleanedValues); + } + } + } + + organization.setAttributes(normalizedAttributes.isEmpty() ? null : normalizedAttributes); + } + + // Normalize domains + if (organization.getDomains() != null && !organization.getDomains().isEmpty()) { + organization.getDomains().forEach(domain -> { + if (domain != null && domain.getName() != null) { + // Normalize domain name to lowercase + domain.setName(domain.getName().toLowerCase().trim()); + } + }); + + // Remove any domains with null or empty names + organization.getDomains().removeIf(domain -> + domain == null || !StringUtils.hasText(domain.getName()) + ); + } + + // Normalize identity provider configurations + if (organization.getIdentityProviders() != null) { + // Remove duplicates and nulls, normalize domains + List uniqueIdps = new ArrayList<>(); + Set seen = new HashSet<>(); + + for (OrganizationIdentityProviderConfig idpConfig : organization.getIdentityProviders()) { + if (idpConfig != null && StringUtils.hasText(idpConfig.getAlias()) + && seen.add(idpConfig.getAlias().trim())) { + // Normalize the alias + idpConfig.setAlias(idpConfig.getAlias().trim()); + + // Normalize the domain to lowercase + if (idpConfig.getDomain() != null) { + idpConfig.setDomain(idpConfig.getDomain().toLowerCase().trim()); + } + + uniqueIdps.add(idpConfig); + } + } + + organization.setIdentityProviders(uniqueIdps.isEmpty() ? null : uniqueIdps); + } + + // Log normalization actions + logger.debug("Normalized organization '{}' with alias '{}'", + organization.getName(), organization.getAlias()); + } +} \ No newline at end of file diff --git a/src/main/java/de/adorsys/keycloak/config/service/state/StateService.java b/src/main/java/de/adorsys/keycloak/config/service/state/StateService.java index 8913c53a7..93d801389 100644 --- a/src/main/java/de/adorsys/keycloak/config/service/state/StateService.java +++ b/src/main/java/de/adorsys/keycloak/config/service/state/StateService.java @@ -20,6 +20,7 @@ package de.adorsys.keycloak.config.service.state; +import de.adorsys.keycloak.config.model.OrganizationRepresentation; import de.adorsys.keycloak.config.model.RealmImport; import de.adorsys.keycloak.config.properties.ImportConfigProperties; import de.adorsys.keycloak.config.repository.StateRepository; @@ -76,6 +77,7 @@ public void doImport(RealmImport realmImport) { setComponents(realmImport); setClientAuthorizationResources(realmImport); setMessageBundles(realmImport); + setOrganizations(realmImport); stateRepository.update(realmImport); logger.debug("Updated states of realm '{}'", realmImport.getRealm()); @@ -167,6 +169,10 @@ public List getClients() { return stateRepository.getState("clients"); } + public List getOrganizations() { + return stateRepository.getState("organizations"); + } + private void setRequiredActions(RealmImport realmImport) { List requiredActions = realmImport.getRequiredActions(); if (requiredActions == null) return; @@ -237,4 +243,15 @@ private void setMessageBundles(RealmImport realmImport) { public List getMessageBundles() { return stateRepository.getState("message-bundles"); } + + private void setOrganizations(RealmImport realmImport) { + List organizations = realmImport.getOrganizationList(); + if (organizations == null) return; + + List state = organizations.stream() + .map(OrganizationRepresentation::getAlias) + .toList(); + + stateRepository.setState("organizations", state); + } } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index cae13948a..fd8c4b67b 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -54,6 +54,7 @@ import.managed.client-authorization-resources=full import.managed.client-authorization-policies=full import.managed.client-authorization-scopes=full import.managed.message-bundles=full +import.managed.organization=full logging.group.http=org.apache.http.wire logging.group.realm-config=de.adorsys.keycloak.config.provider.KeycloakImportProvider diff --git a/src/test/java/de/adorsys/keycloak/config/AbstractImportIT.java b/src/test/java/de/adorsys/keycloak/config/AbstractImportIT.java index d835860c0..05d0754a9 100644 --- a/src/test/java/de/adorsys/keycloak/config/AbstractImportIT.java +++ b/src/test/java/de/adorsys/keycloak/config/AbstractImportIT.java @@ -86,6 +86,11 @@ abstract public class AbstractImportIT extends AbstractImportTest { if (VersionUtil.lt(KEYCLOAK_VERSION, "24")) { featuresBuilder.append(",declarative-user-profile"); } + + // Add organization feature for Keycloak 26+ + if (VersionUtil.ge(KEYCLOAK_VERSION, "26")) { + featuresBuilder.append(",organization"); + } command.add(featuresBuilder.toString()); } diff --git a/src/test/java/de/adorsys/keycloak/config/configuration/NormalizeTestConfiguration.java b/src/test/java/de/adorsys/keycloak/config/configuration/NormalizeTestConfiguration.java index a27f1e142..2afa16d33 100644 --- a/src/test/java/de/adorsys/keycloak/config/configuration/NormalizeTestConfiguration.java +++ b/src/test/java/de/adorsys/keycloak/config/configuration/NormalizeTestConfiguration.java @@ -26,9 +26,14 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.FilterType; @Configuration -@ComponentScan(basePackages = {"de.adorsys.keycloak.config"}) +@ComponentScan(basePackages = {"de.adorsys.keycloak.config"}, + excludeFilters = @ComponentScan.Filter( + type = FilterType.REGEX, + pattern = "de.adorsys.keycloak.config.repository.Organization.*" + )) @EnableConfigurationProperties({NormalizationKeycloakConfigProperties.class, NormalizationConfigProperties.class}) @ConditionalOnProperty(prefix = "run", name = "operation", havingValue = "NORMALIZE") public class NormalizeTestConfiguration { diff --git a/src/test/java/de/adorsys/keycloak/config/model/OrganizationIdentityProviderConfigTest.java b/src/test/java/de/adorsys/keycloak/config/model/OrganizationIdentityProviderConfigTest.java new file mode 100644 index 000000000..ca2137511 --- /dev/null +++ b/src/test/java/de/adorsys/keycloak/config/model/OrganizationIdentityProviderConfigTest.java @@ -0,0 +1,92 @@ +/*- + * ---license-start + * keycloak-config-cli + * --- + * Copyright (C) 2017 - 2025 adorsys GmbH & Co. KG @ https://adorsys.com + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ---license-end + */ + +package de.adorsys.keycloak.config.model; + +import org.junit.jupiter.api.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; + +class OrganizationIdentityProviderConfigTest { + + @Test + void shouldCreateConfigWithAlias() { + OrganizationIdentityProviderConfig config = new OrganizationIdentityProviderConfig("test-alias"); + + assertThat(config.getAlias(), equalTo("test-alias")); + assertThat(config.getDomain(), nullValue()); + assertThat(config.getRedirectWhenEmailMatches(), nullValue()); + assertThat(config.getHideOnLogin(), nullValue()); + } + + @Test + void shouldSetAndGetAllProperties() { + OrganizationIdentityProviderConfig config = new OrganizationIdentityProviderConfig(); + config.setAlias("corp-saml"); + config.setDomain("corp.example.com"); + config.setRedirectWhenEmailMatches(true); + config.setHideOnLogin(false); + + assertThat(config.getAlias(), equalTo("corp-saml")); + assertThat(config.getDomain(), equalTo("corp.example.com")); + assertThat(config.getRedirectWhenEmailMatches(), equalTo(true)); + assertThat(config.getHideOnLogin(), equalTo(false)); + } + + @Test + void shouldBeEqualBasedOnAlias() { + OrganizationIdentityProviderConfig config1 = new OrganizationIdentityProviderConfig("test-alias"); + config1.setDomain("domain1.com"); + + OrganizationIdentityProviderConfig config2 = new OrganizationIdentityProviderConfig("test-alias"); + config2.setDomain("domain2.com"); + + OrganizationIdentityProviderConfig config3 = new OrganizationIdentityProviderConfig("different-alias"); + + assertThat(config1, equalTo(config2)); + assertThat(config1, not(equalTo(config3))); + assertThat(config1.hashCode(), equalTo(config2.hashCode())); + assertThat(config1.hashCode(), not(equalTo(config3.hashCode()))); + } + + @Test + void shouldHandleNullsInEquals() { + OrganizationIdentityProviderConfig config = new OrganizationIdentityProviderConfig("test"); + + assertThat(config.equals(null), is(false)); + assertThat(config.equals(config), is(true)); + assertThat(config.equals(new Object()), is(false)); + } + + @Test + void shouldGenerateReadableToString() { + OrganizationIdentityProviderConfig config = new OrganizationIdentityProviderConfig("corp-saml"); + config.setDomain("corp.example.com"); + config.setRedirectWhenEmailMatches(true); + config.setHideOnLogin(false); + + String toString = config.toString(); + assertThat(toString, containsString("corp-saml")); + assertThat(toString, containsString("corp.example.com")); + assertThat(toString, containsString("true")); + assertThat(toString, containsString("false")); + } +} \ No newline at end of file diff --git a/src/test/java/de/adorsys/keycloak/config/repository/OrganizationRepositoryTest.java b/src/test/java/de/adorsys/keycloak/config/repository/OrganizationRepositoryTest.java new file mode 100644 index 000000000..dcade9cdf --- /dev/null +++ b/src/test/java/de/adorsys/keycloak/config/repository/OrganizationRepositoryTest.java @@ -0,0 +1,165 @@ +/*- + * ---license-start + * keycloak-config-cli + * --- + * Copyright (C) 2017 - 2025 adorsys GmbH & Co. KG @ https://adorsys.com + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ---license-end + */ + +package de.adorsys.keycloak.config.repository; + +import de.adorsys.keycloak.config.model.OrganizationRepresentation; +import de.adorsys.keycloak.config.provider.KeycloakProvider; +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 static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Unit tests for OrganizationRepository input validation. + * These tests focus on the defensive programming improvements added to ensure + * proper input validation and fail-fast behavior. + */ +@ExtendWith(MockitoExtension.class) +class OrganizationRepositoryTest { + + @Mock + private KeycloakProvider keycloakProvider; + + @Mock + private IdentityProviderRepository identityProviderRepository; + + private OrganizationRepository organizationRepository; + + @BeforeEach + void setUp() { + organizationRepository = new OrganizationRepository(keycloakProvider, identityProviderRepository); + // No API stubbing needed - these tests only validate input parameters + } + + @Test + void create_shouldThrowException_whenRealmIsNull() { + OrganizationRepresentation org = new OrganizationRepresentation(); + org.setAlias("test-org"); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> organizationRepository.create(null, org) + ); + + assert exception.getMessage().equals("Realm cannot be null or empty"); + } + + @Test + void create_shouldThrowException_whenRealmIsEmpty() { + OrganizationRepresentation org = new OrganizationRepresentation(); + org.setAlias("test-org"); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> organizationRepository.create("", org) + ); + + assert exception.getMessage().equals("Realm cannot be null or empty"); + } + + @Test + void create_shouldThrowException_whenOrganizationIsNull() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> organizationRepository.create("test-realm", null) + ); + + assert exception.getMessage().equals("Organization cannot be null"); + } + + @Test + void create_shouldThrowException_whenOrganizationAliasIsNull() { + OrganizationRepresentation org = new OrganizationRepresentation(); + // alias is null by default + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> organizationRepository.create("test-realm", org) + ); + + assert exception.getMessage().equals("Organization alias cannot be null or empty"); + } + + @Test + void create_shouldThrowException_whenOrganizationAliasIsEmpty() { + OrganizationRepresentation org = new OrganizationRepresentation(); + org.setAlias(""); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> organizationRepository.create("test-realm", org) + ); + + assert exception.getMessage().equals("Organization alias cannot be null or empty"); + } + + @Test + void update_shouldThrowException_whenRealmIsNull() { + OrganizationRepresentation org = new OrganizationRepresentation(); + org.setId("test-id"); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> organizationRepository.update(null, org) + ); + + assert exception.getMessage().equals("Realm cannot be null or empty"); + } + + @Test + void update_shouldThrowException_whenOrganizationIsNull() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> organizationRepository.update("test-realm", null) + ); + + assert exception.getMessage().equals("Organization cannot be null"); + } + + @Test + void update_shouldThrowException_whenOrganizationIdIsNull() { + OrganizationRepresentation org = new OrganizationRepresentation(); + // id is null by default + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> organizationRepository.update("test-realm", org) + ); + + assert exception.getMessage().equals("Organization ID cannot be null or empty"); + } + + @Test + void update_shouldThrowException_whenOrganizationIdIsEmpty() { + OrganizationRepresentation org = new OrganizationRepresentation(); + org.setId(""); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> organizationRepository.update("test-realm", org) + ); + + assert exception.getMessage().equals("Organization ID cannot be null or empty"); + } +} \ No newline at end of file diff --git a/src/test/java/de/adorsys/keycloak/config/service/ImportOrganizationsIT.java b/src/test/java/de/adorsys/keycloak/config/service/ImportOrganizationsIT.java new file mode 100644 index 000000000..35bd3a55a --- /dev/null +++ b/src/test/java/de/adorsys/keycloak/config/service/ImportOrganizationsIT.java @@ -0,0 +1,338 @@ +/*- + * ---license-start + * keycloak-config-cli + * --- + * Copyright (C) 2017 - 2025 adorsys GmbH & Co. KG @ https://adorsys.com + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ---license-end + */ + +package de.adorsys.keycloak.config.service; + +import de.adorsys.keycloak.config.AbstractImportIT; +import de.adorsys.keycloak.config.exception.ImportProcessingException; +import de.adorsys.keycloak.config.model.OrganizationIdentityProviderConfig; +import de.adorsys.keycloak.config.model.OrganizationRepresentation; +import de.adorsys.keycloak.config.repository.IdentityProviderRepository; +import de.adorsys.keycloak.config.repository.OrganizationRepository; +import de.adorsys.keycloak.config.util.VersionUtil; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledIf; +import org.keycloak.representations.idm.IdentityProviderRepresentation; +import org.springframework.beans.factory.annotation.Autowired; + +import java.io.IOException; +import java.util.List; +import java.util.Optional; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Integration tests for Organization import functionality. + * Note: Organizations are only available in Keycloak 26+ + */ +@DisabledIf("isKeycloakVersionLessThan26") +@SuppressWarnings({"java:S5961", "java:S5976"}) +class ImportOrganizationsIT extends AbstractImportIT { + private static final String REALM_NAME = "realmWithOrganizations"; + + @Autowired + private OrganizationRepository organizationRepository; + + @Autowired + private IdentityProviderRepository identityProviderRepository; + + ImportOrganizationsIT() { + this.resourcePath = "import-files/organizations"; + } + + private static boolean isKeycloakVersionLessThan26() { + return VersionUtil.lt(KEYCLOAK_VERSION, "26"); + } + + @Test + @Order(0) + void shouldCreateRealmWithOrganization() throws IOException { + doImport("00_create_realm_with_organization.json"); + + List organizations = organizationRepository.getAll(REALM_NAME); + assertThat(organizations, hasSize(1)); + + OrganizationRepresentation org = organizations.get(0); + assertThat(org.getName(), is("Test Organization")); + assertThat(org.getAlias(), is("test-org")); + assertThat(org.getEnabled(), is(true)); + assertThat(org.getDescription(), is("A test organization")); + assertThat(org.getRedirectUrl(), is("https://test-org.example.com")); + + // Check domains + assertThat(org.getDomains(), hasSize(2)); + assertThat(org.getDomain("test-org.com"), notNullValue()); + assertThat(org.getDomain("test-org.com").isVerified(), is(Boolean.TRUE)); + assertThat(org.getDomain("test-org.org"), notNullValue()); + assertThat(org.getDomain("test-org.org").isVerified(), is(Boolean.FALSE)); + + // Check attributes + assertThat(org.getAttributes(), notNullValue()); + assertThat(org.getAttributes().get("contactEmail"), contains("admin@test-org.com")); + assertThat(org.getAttributes().get("contactPhone"), containsInAnyOrder("+1234567890", "+0987654321")); + } + + @Test + @Order(1) + void shouldUpdateOrganization() throws IOException { + // Create a JSON that updates the organization + doImport("01_update_organization_properties.json"); + + Optional maybeOrg = organizationRepository.getByAlias(REALM_NAME, "test-org"); + assertThat(maybeOrg.isPresent(), is(true)); + + OrganizationRepresentation org = maybeOrg.get(); + assertThat(org.getName(), is("Updated Test Organization")); + assertThat(org.getDescription(), is("An updated test organization")); + assertThat(org.getRedirectUrl(), is("https://updated.test-org.com")); + } + + @Test + @Order(2) + void shouldAddDomainToOrganization() throws IOException { + doImport("02_update_organization_add_domain.json"); + + Optional maybeOrg = organizationRepository.getByAlias(REALM_NAME, "test-org"); + assertThat(maybeOrg.isPresent(), is(true)); + + OrganizationRepresentation org = maybeOrg.get(); + assertThat(org.getDomains(), hasSize(3)); + assertThat(org.getDomain("test-org.net"), notNullValue()); + assertThat(org.getDomain("test-org.net").isVerified(), is(Boolean.TRUE)); + } + + @Test + @Order(3) + void shouldFailWhenOrganizationHasEmptyAlias() { + ImportProcessingException thrown = assertThrows( + ImportProcessingException.class, + () -> doImport("03_update_organization_empty_alias.json") + ); + + assertThat(thrown.getMessage(), containsString("Organization alias cannot be null or empty")); + } + + @Test + @Order(10) + void shouldFailWhenLinkingNonExistentIdentityProvider() { + ImportProcessingException thrown = assertThrows( + ImportProcessingException.class, + () -> doImport("10_update_organization_link_non_existent_idp.json") + ); + + assertThat(thrown.getMessage(), containsString("Identity provider does not exist")); + } + + @Test + @Order(4) + void shouldLinkIdentityProviderWithConfiguration() throws IOException { + doImport("04_update_organization_idp_config.json"); + + Optional maybeOrg = organizationRepository.getByAlias(REALM_NAME, "test-org"); + assertThat(maybeOrg.isPresent(), is(true)); + + OrganizationRepresentation org = maybeOrg.get(); + assertThat(org.getIdentityProviders(), hasSize(1)); + + OrganizationIdentityProviderConfig idpConfig = org.getIdentityProviders().get(0); + assertThat(idpConfig.getAlias(), equalTo("oidc-test")); + assertThat(idpConfig.getDomain(), equalTo("example.org")); + assertThat(idpConfig.getRedirectWhenEmailMatches(), equalTo(true)); + assertThat(idpConfig.getHideOnLogin(), equalTo(true)); + + // Verify the identity provider configuration was updated + Optional maybeIdp = identityProviderRepository.search(REALM_NAME, "oidc-test"); + assertThat(maybeIdp.isPresent(), is(true)); + + IdentityProviderRepresentation idp = maybeIdp.get(); + assertThat(idp.getConfig().get("kc.org.domain"), equalTo("example.org")); + assertThat(idp.getConfig().get("kc.org.broker.redirect.mode.email-matches"), equalTo("true")); + assertThat(Boolean.parseBoolean(idp.getConfig().get("hideOnLoginPage")), equalTo(true)); + } + + + @Test + @Order(6) + void shouldHandleMultipleIdentityProviders() throws IOException { + doImport("06_update_organization_multiple_idps.json"); + + Optional maybeOrg = organizationRepository.getByAlias(REALM_NAME, "test-org"); + assertThat(maybeOrg.isPresent(), is(true)); + + OrganizationRepresentation org = maybeOrg.get(); + assertThat(org.getIdentityProviders(), hasSize(2)); + + // Check first IdP + OrganizationIdentityProviderConfig idpConfig1 = org.getIdentityProviders().get(0); + assertThat(idpConfig1.getAlias(), equalTo("saml-test")); + assertThat(idpConfig1.getDomain(), equalTo("saml.example.org")); + assertThat(idpConfig1.getRedirectWhenEmailMatches(), equalTo(true)); + assertThat(idpConfig1.getHideOnLogin(), equalTo(false)); + + // Check second IdP + OrganizationIdentityProviderConfig idpConfig2 = org.getIdentityProviders().get(1); + assertThat(idpConfig2.getAlias(), equalTo("keycloak-oidc")); + assertThat(idpConfig2.getDomain(), nullValue()); + assertThat(idpConfig2.getRedirectWhenEmailMatches(), equalTo(false)); + assertThat(idpConfig2.getHideOnLogin(), equalTo(true)); + } + + @Test + @Order(7) + void shouldNormalizeIdentityProviderDomains() throws IOException { + doImport("07_update_organization_idp_domain_normalization.json"); + + Optional maybeOrg = organizationRepository.getByAlias(REALM_NAME, "test-org"); + assertThat(maybeOrg.isPresent(), is(true)); + + OrganizationRepresentation org = maybeOrg.get(); + assertThat(org.getIdentityProviders(), hasSize(1)); + + OrganizationIdentityProviderConfig idpConfig = org.getIdentityProviders().get(0); + assertThat(idpConfig.getDomain(), equalTo("example.org")); // Should be normalized to lowercase + + // Verify the identity provider configuration was updated with normalized domain + Optional maybeIdp = identityProviderRepository.search(REALM_NAME, "oidc-test"); + assertThat(maybeIdp.isPresent(), is(true)); + + IdentityProviderRepresentation idp = maybeIdp.get(); + assertThat(idp.getConfig().get("kc.org.domain"), equalTo("example.org")); + } + + @Test + @Order(8) + void shouldRemoveAllIdentityProviderLinks() throws IOException { + // First ensure we have IdP links from previous tests + Optional maybeBefore = organizationRepository.getByAlias(REALM_NAME, "test-org"); + assertThat(maybeBefore.isPresent(), is(true)); + assertThat(maybeBefore.get().getIdentityProviders(), not(empty())); + + // Now remove all IdP links + doImport("08_update_organization_remove_idp_links.json"); + + Optional maybeOrg = organizationRepository.getByAlias(REALM_NAME, "test-org"); + assertThat(maybeOrg.isPresent(), is(true)); + + OrganizationRepresentation org = maybeOrg.get(); + // In FULL mode, all IdP links should be removed + assertThat(org.getIdentityProviders(), anyOf(nullValue(), empty())); + } + + @Test + @Order(9) + void shouldUpdateExistingIdentityProviderConfiguration() throws IOException { + // First, set up initial IdP configuration + doImport("04_update_organization_idp_config.json"); + + // Now update the configuration + doImport("09_update_organization_modify_idp_config.json"); + + Optional maybeOrg = organizationRepository.getByAlias(REALM_NAME, "test-org"); + assertThat(maybeOrg.isPresent(), is(true)); + + OrganizationRepresentation org = maybeOrg.get(); + assertThat(org.getIdentityProviders(), hasSize(1)); + + OrganizationIdentityProviderConfig idpConfig = org.getIdentityProviders().get(0); + assertThat(idpConfig.getAlias(), equalTo("oidc-test")); + assertThat(idpConfig.getDomain(), equalTo("updated.example.org")); + assertThat(idpConfig.getRedirectWhenEmailMatches(), equalTo(false)); + assertThat(idpConfig.getHideOnLogin(), equalTo(false)); + + // Verify the identity provider configuration was updated + Optional maybeIdp = identityProviderRepository.search(REALM_NAME, "oidc-test"); + assertThat(maybeIdp.isPresent(), is(true)); + + IdentityProviderRepresentation idp = maybeIdp.get(); + assertThat(idp.getConfig().get("kc.org.domain"), equalTo("updated.example.org")); + assertThat(idp.getConfig().get("kc.org.broker.redirect.mode.email-matches"), equalTo("false")); + assertThat(Boolean.parseBoolean(idp.getConfig().get("hideOnLoginPage")), equalTo(false)); + } + + @Test + @Order(10) + void shouldUpdateAlreadyLinkedIdentityProviderConfiguration() throws IOException { + // First ensure we have an IdP linked with specific configuration + doImport("04_update_organization_idp_config.json"); + + // Verify initial configuration + Optional maybeIdpBefore = identityProviderRepository.search(REALM_NAME, "oidc-test"); + assertThat(maybeIdpBefore.isPresent(), is(true)); + IdentityProviderRepresentation idpBefore = maybeIdpBefore.get(); + assertThat(idpBefore.getConfig().get("kc.org.domain"), equalTo("example.org")); + assertThat(idpBefore.getConfig().get("kc.org.broker.redirect.mode.email-matches"), equalTo("true")); + assertThat(Boolean.parseBoolean(idpBefore.getConfig().get("hideOnLoginPage")), equalTo(true)); + + // Now update the configuration of the already-linked IdP + doImport("10_update_linked_idp_config.json"); + + // Verify the IdP configuration was updated + Optional maybeIdpAfter = identityProviderRepository.search(REALM_NAME, "oidc-test"); + assertThat(maybeIdpAfter.isPresent(), is(true)); + IdentityProviderRepresentation idpAfter = maybeIdpAfter.get(); + assertThat(idpAfter.getConfig().get("kc.org.domain"), equalTo("modified.example.org")); + assertThat(idpAfter.getConfig().get("kc.org.broker.redirect.mode.email-matches"), equalTo("false")); + assertThat(Boolean.parseBoolean(idpAfter.getConfig().get("hideOnLoginPage")), equalTo(false)); + + // Verify the organization still has the IdP linked + Optional maybeOrg = organizationRepository.getByAlias(REALM_NAME, "test-org"); + assertThat(maybeOrg.isPresent(), is(true)); + assertThat(maybeOrg.get().getIdentityProviders(), hasSize(1)); + } + + @Test + @Order(11) + void shouldRemoveIdentityProviderConfigurationWhenNull() throws IOException { + // First ensure we have an IdP linked with configuration + doImport("10_update_linked_idp_config.json"); + + // Verify configuration exists + Optional maybeIdpBefore = identityProviderRepository.search(REALM_NAME, "oidc-test"); + assertThat(maybeIdpBefore.isPresent(), is(true)); + IdentityProviderRepresentation idpBefore = maybeIdpBefore.get(); + assertThat(idpBefore.getConfig().containsKey("kc.org.domain"), is(true)); + assertThat(idpBefore.getConfig().containsKey("kc.org.broker.redirect.mode.email-matches"), is(true)); + + // Now import with no configuration values (should remove them) + doImport("11_update_linked_idp_remove_config.json"); + + // Verify the configuration was removed + Optional maybeIdpAfter = identityProviderRepository.search(REALM_NAME, "oidc-test"); + assertThat(maybeIdpAfter.isPresent(), is(true)); + IdentityProviderRepresentation idpAfter = maybeIdpAfter.get(); + assertThat(idpAfter.getConfig().containsKey("kc.org.domain"), is(false)); + assertThat(idpAfter.getConfig().containsKey("kc.org.broker.redirect.mode.email-matches"), is(false)); + // hideOnLogin should remain unchanged (we didn't specify it) + assertThat(Boolean.parseBoolean(idpAfter.getConfig().get("hideOnLoginPage")), equalTo(false)); + } + + @Test + @Order(98) + void shouldDeleteOrganizationInFullMode() throws IOException { + // Assuming we're in FULL mode by default + doImport("98_update_realm_remove_organization.json"); + + List organizations = organizationRepository.getAll(REALM_NAME); + assertThat(organizations, empty()); + } +} \ No newline at end of file diff --git a/src/test/resources/import-files/organizations/00_create_realm_with_organization.json b/src/test/resources/import-files/organizations/00_create_realm_with_organization.json new file mode 100644 index 000000000..a0ae87717 --- /dev/null +++ b/src/test/resources/import-files/organizations/00_create_realm_with_organization.json @@ -0,0 +1,28 @@ +{ + "realm": "realmWithOrganizations", + "enabled": true, + "organizationsEnabled": true, + "organizations": [ + { + "alias": "test-org", + "name": "Test Organization", + "enabled": true, + "description": "A test organization", + "redirectUrl": "https://test-org.example.com", + "domains": [ + { + "name": "test-org.com", + "verified": true + }, + { + "name": "test-org.org", + "verified": false + } + ], + "attributes": { + "contactEmail": ["admin@test-org.com"], + "contactPhone": ["+1234567890", "+0987654321"] + } + } + ] +} \ No newline at end of file diff --git a/src/test/resources/import-files/organizations/01_update_organization_properties.json b/src/test/resources/import-files/organizations/01_update_organization_properties.json new file mode 100644 index 000000000..c26e7136c --- /dev/null +++ b/src/test/resources/import-files/organizations/01_update_organization_properties.json @@ -0,0 +1,28 @@ +{ + "realm": "realmWithOrganizations", + "enabled": true, + "organizationsEnabled": true, + "organizations": [ + { + "alias": "test-org", + "name": "Updated Test Organization", + "enabled": true, + "description": "An updated test organization", + "redirectUrl": "https://updated.test-org.com", + "domains": [ + { + "name": "test-org.com", + "verified": true + }, + { + "name": "test-org.org", + "verified": false + } + ], + "attributes": { + "contactEmail": ["admin@test-org.com"], + "contactPhone": ["+1234567890", "+0987654321"] + } + } + ] +} \ No newline at end of file diff --git a/src/test/resources/import-files/organizations/02_update_organization_add_domain.json b/src/test/resources/import-files/organizations/02_update_organization_add_domain.json new file mode 100644 index 000000000..c1a6d6b29 --- /dev/null +++ b/src/test/resources/import-files/organizations/02_update_organization_add_domain.json @@ -0,0 +1,32 @@ +{ + "realm": "realmWithOrganizations", + "enabled": true, + "organizationsEnabled": true, + "organizations": [ + { + "alias": "test-org", + "name": "Updated Test Organization", + "enabled": true, + "description": "An updated test organization", + "redirectUrl": "https://updated.test-org.com", + "domains": [ + { + "name": "test-org.com", + "verified": true + }, + { + "name": "test-org.org", + "verified": false + }, + { + "name": "test-org.net", + "verified": true + } + ], + "attributes": { + "contactEmail": ["admin@test-org.com"], + "contactPhone": ["+1234567890", "+0987654321"] + } + } + ] +} \ No newline at end of file diff --git a/src/test/resources/import-files/organizations/03_update_organization_empty_alias.json b/src/test/resources/import-files/organizations/03_update_organization_empty_alias.json new file mode 100644 index 000000000..5b5f31815 --- /dev/null +++ b/src/test/resources/import-files/organizations/03_update_organization_empty_alias.json @@ -0,0 +1,12 @@ +{ + "realm": "realmWithOrganizations", + "enabled": true, + "organizationsEnabled": true, + "organizations": [ + { + "alias": "", + "name": "Organization without alias", + "enabled": true + } + ] +} \ No newline at end of file diff --git a/src/test/resources/import-files/organizations/04_update_organization_idp_config.json b/src/test/resources/import-files/organizations/04_update_organization_idp_config.json new file mode 100644 index 000000000..356c1e46e --- /dev/null +++ b/src/test/resources/import-files/organizations/04_update_organization_idp_config.json @@ -0,0 +1,41 @@ +{ + "enabled": true, + "realm": "realmWithOrganizations", + "organizationsEnabled": true, + "organizations": [ + { + "alias": "test-org", + "name": "Test Organization", + "enabled": true, + "description": "Test organization for integration tests", + "domains": [ + { + "name": "example.org", + "verified": true + } + ], + "identityProviders": [ + { + "alias": "oidc-test", + "domain": "example.org", + "redirectWhenEmailMatches": true, + "hideOnLogin": true + } + ] + } + ], + "identityProviders": [ + { + "alias": "oidc-test", + "displayName": "OIDC Test Provider", + "providerId": "oidc", + "enabled": true, + "config": { + "authorizationUrl": "https://example.org/auth", + "tokenUrl": "https://example.org/token", + "clientId": "test-client", + "clientSecret": "test-secret" + } + } + ] +} \ No newline at end of file diff --git a/src/test/resources/import-files/organizations/06_update_organization_multiple_idps.json b/src/test/resources/import-files/organizations/06_update_organization_multiple_idps.json new file mode 100644 index 000000000..e6cb6666f --- /dev/null +++ b/src/test/resources/import-files/organizations/06_update_organization_multiple_idps.json @@ -0,0 +1,50 @@ +{ + "enabled": true, + "realm": "realmWithOrganizations", + "organizationsEnabled": true, + "organizations": [ + { + "alias": "test-org", + "name": "Test Organization", + "enabled": true, + "description": "Test organization with mixed IdP formats", + "identityProviders": [ + { + "alias": "saml-test", + "domain": "saml.example.org", + "redirectWhenEmailMatches": true, + "hideOnLogin": false + }, + { + "alias": "keycloak-oidc", + "redirectWhenEmailMatches": false, + "hideOnLogin": true + } + ] + } + ], + "identityProviders": [ + { + "alias": "saml-test", + "displayName": "SAML Test Provider", + "providerId": "saml", + "enabled": true, + "config": { + "singleSignOnServiceUrl": "https://saml.example.org/sso", + "entityId": "saml-test-entity" + } + }, + { + "alias": "keycloak-oidc", + "displayName": "Keycloak OIDC Provider", + "providerId": "keycloak-oidc", + "enabled": true, + "config": { + "authorizationUrl": "https://auth.example.org/auth", + "tokenUrl": "https://auth.example.org/token", + "clientId": "keycloak-client", + "clientSecret": "keycloak-secret" + } + } + ] +} \ No newline at end of file diff --git a/src/test/resources/import-files/organizations/07_update_organization_idp_domain_normalization.json b/src/test/resources/import-files/organizations/07_update_organization_idp_domain_normalization.json new file mode 100644 index 000000000..8e584468b --- /dev/null +++ b/src/test/resources/import-files/organizations/07_update_organization_idp_domain_normalization.json @@ -0,0 +1,21 @@ +{ + "enabled": true, + "realm": "realmWithOrganizations", + "organizationsEnabled": true, + "organizations": [ + { + "alias": "test-org", + "name": "Test Organization", + "enabled": true, + "description": "Test domain normalization", + "identityProviders": [ + { + "alias": "oidc-test", + "domain": "EXAMPLE.ORG", + "redirectWhenEmailMatches": true, + "hideOnLogin": false + } + ] + } + ] +} \ No newline at end of file diff --git a/src/test/resources/import-files/organizations/08_update_organization_remove_idp_links.json b/src/test/resources/import-files/organizations/08_update_organization_remove_idp_links.json new file mode 100644 index 000000000..18fff487f --- /dev/null +++ b/src/test/resources/import-files/organizations/08_update_organization_remove_idp_links.json @@ -0,0 +1,14 @@ +{ + "enabled": true, + "realm": "realmWithOrganizations", + "organizationsEnabled": true, + "organizations": [ + { + "alias": "test-org", + "name": "Test Organization", + "enabled": true, + "description": "Test organization with no IdPs", + "identityProviders": [] + } + ] +} \ No newline at end of file diff --git a/src/test/resources/import-files/organizations/09_update_organization_modify_idp_config.json b/src/test/resources/import-files/organizations/09_update_organization_modify_idp_config.json new file mode 100644 index 000000000..3081171b5 --- /dev/null +++ b/src/test/resources/import-files/organizations/09_update_organization_modify_idp_config.json @@ -0,0 +1,21 @@ +{ + "enabled": true, + "realm": "realmWithOrganizations", + "organizationsEnabled": true, + "organizations": [ + { + "alias": "test-org", + "name": "Test Organization", + "enabled": true, + "description": "Test updating IdP configuration", + "identityProviders": [ + { + "alias": "oidc-test", + "domain": "updated.example.org", + "redirectWhenEmailMatches": false, + "hideOnLogin": false + } + ] + } + ] +} \ No newline at end of file diff --git a/src/test/resources/import-files/organizations/10_update_linked_idp_config.json b/src/test/resources/import-files/organizations/10_update_linked_idp_config.json new file mode 100644 index 000000000..5a17393e0 --- /dev/null +++ b/src/test/resources/import-files/organizations/10_update_linked_idp_config.json @@ -0,0 +1,27 @@ +{ + "enabled": true, + "realm": "realmWithOrganizations", + "organizationsEnabled": true, + "organizations": [ + { + "alias": "test-org", + "name": "Test Organization", + "enabled": true, + "description": "Test organization for integration tests", + "domains": [ + { + "name": "example.org", + "verified": true + } + ], + "identityProviders": [ + { + "alias": "oidc-test", + "domain": "modified.example.org", + "redirectWhenEmailMatches": false, + "hideOnLogin": false + } + ] + } + ] +} \ No newline at end of file diff --git a/src/test/resources/import-files/organizations/10_update_organization_link_non_existent_idp.json b/src/test/resources/import-files/organizations/10_update_organization_link_non_existent_idp.json new file mode 100644 index 000000000..19674274e --- /dev/null +++ b/src/test/resources/import-files/organizations/10_update_organization_link_non_existent_idp.json @@ -0,0 +1,21 @@ +{ + "realm": "realmWithOrganizations", + "enabled": true, + "organizationsEnabled": true, + "organizations": [ + { + "alias": "test-org", + "name": "Updated Test Organization", + "enabled": true, + "description": "An updated test organization", + "redirectUrl": "https://updated.test-org.com", + "domains": [ + { + "name": "test-org.com", + "verified": true + } + ], + "identityProviders": ["non-existent-idp"] + } + ] +} \ No newline at end of file diff --git a/src/test/resources/import-files/organizations/11_update_linked_idp_remove_config.json b/src/test/resources/import-files/organizations/11_update_linked_idp_remove_config.json new file mode 100644 index 000000000..bbc9acff8 --- /dev/null +++ b/src/test/resources/import-files/organizations/11_update_linked_idp_remove_config.json @@ -0,0 +1,24 @@ +{ + "enabled": true, + "realm": "realmWithOrganizations", + "organizationsEnabled": true, + "organizations": [ + { + "alias": "test-org", + "name": "Test Organization", + "enabled": true, + "description": "Test organization for integration tests", + "domains": [ + { + "name": "example.org", + "verified": true + } + ], + "identityProviders": [ + { + "alias": "oidc-test" + } + ] + } + ] +} \ No newline at end of file diff --git a/src/test/resources/import-files/organizations/98_update_realm_remove_organization.json b/src/test/resources/import-files/organizations/98_update_realm_remove_organization.json new file mode 100644 index 000000000..8f47069c4 --- /dev/null +++ b/src/test/resources/import-files/organizations/98_update_realm_remove_organization.json @@ -0,0 +1,6 @@ +{ + "realm": "realmWithOrganizations", + "enabled": true, + "organizationsEnabled": true, + "organizations": [] +} \ No newline at end of file diff --git a/src/test/resources/import-files/simple-realm/04_create_simple-realm_with_invalid_name.json b/src/test/resources/import-files/simple-realm/04_create_simple-realm_with_invalid_name.json index a521a31b5..022678796 100644 --- a/src/test/resources/import-files/simple-realm/04_create_simple-realm_with_invalid_name.json +++ b/src/test/resources/import-files/simple-realm/04_create_simple-realm_with_invalid_name.json @@ -1,3 +1,3 @@ { - "realm": "quohl8uP1owo1chouz8Iod8eeLaitheeKeech9shoo5rooguc4xosh3yiesh1que5ooKogeThah6Quoo0EFeif8eiPi2iedahgaid1thigeiSieheixeo9Eexeele8re5hohzo2wooChungoo1xaCeif8Vii4ohwe2aeda9aixeiw1Eph8apha8ia7vew3ii2Cai4uVaidohShooch8ii0ehieB6aiwie5oxeuLahphoeshielicheu9cadai2gi" + "realm": "quohl8uP1owo1chouz8Iod8eeLaitheeKeech9shoo5rooguc4xosh3yiesh1que5ooKogeThah6Quoo0EFeif8eiPi2iedahgaid1thigeiSieheixeo9Eexeele8re5hohzo2wooChungoo1xaCeif8Vii4ohwe2aeda9aixeiw1Eph8apha8ia7vew3ii2Cai4uVaidohShooch8ii0ehieB6aiwie5oxeuLahphoeshielicheu9cadai2g" } diff --git a/src/test/resources/import-files/simple-realm/09_update_simple-realm_with_invalid_property.json b/src/test/resources/import-files/simple-realm/09_update_simple-realm_with_invalid_property.json index 1eca4c2bd..02d79e316 100644 --- a/src/test/resources/import-files/simple-realm/09_update_simple-realm_with_invalid_property.json +++ b/src/test/resources/import-files/simple-realm/09_update_simple-realm_with_invalid_property.json @@ -1,4 +1,4 @@ { "realm": "simple", - "loginTheme": "quohl8uP1owo1chouz8Iod8eeLaitheeKeech9shoo5rooguc4xosh3yiesh1que5ooKogeThah6Quoo0EFeif8eiPi2iedahgaid1thigeiSieheixeo9Eexeele8re5hohzo2wooChungoo1xaCeif8Vii4ohwe2aeda9aixeiw1Eph8apha8ia7vew3ii2Cai4uVaidohShooch8ii0ehieB6aiwie5oxeuLahphoeshielicheu9cadai2gi" + "loginTheme": "quohl8uP1owo1chouz8Iod8eeLaitheeKeech9shoo5rooguc4xosh3yiesh1que5ooKogeThah6Quoo0EFeif8eiPi2iedahgaid1thigeiSieheixeo9Eexeele8re5hohzo2wooChungoo1xaCeif8Vii4ohwe2aeda9aixeiw1Eph8apha8ia7vew3ii2Cai4uVaidohShooch8ii0ehieB6aiwie5oxeuLahphoeshielicheu9cadai2g" }