From 224ccbb79d479d63e5e7d9fd212eaeae80af45bd Mon Sep 17 00:00:00 2001 From: Alexis Rico Date: Mon, 16 Jun 2025 08:45:57 +0200 Subject: [PATCH] Make organization `domains` optional Closes #31285 Signed-off-by: Alexis Rico --- .../organizations/managing-organization.adoc | 2 +- .../src/organizations/OrganizationForm.tsx | 2 - .../jpa/JpaOrganizationProvider.java | 19 +++--- .../organization/jpa/OrganizationAdapter.java | 4 +- .../organization/admin/OrganizationTest.java | 67 +++++++++++++++++++ 5 files changed, 81 insertions(+), 13 deletions(-) diff --git a/docs/documentation/server_admin/topics/organizations/managing-organization.adoc b/docs/documentation/server_admin/topics/organizations/managing-organization.adoc index ec9fa6f80a6..3299ba5a04e 100644 --- a/docs/documentation/server_admin/topics/organizations/managing-organization.adoc +++ b/docs/documentation/server_admin/topics/organizations/managing-organization.adoc @@ -47,7 +47,7 @@ Redirect URL:: After completing registration or accepting an invitation to the organization sent via email, the user is automatically redirected to the specified redirect url. If left empty, the user will be redirected to the account console by default. Domains:: -A set of one or more domains that belongs to this organization. A domain cannot be shared by different organizations within a realm. +A set of zero or more domains that belongs to this organization. A domain cannot be shared by different organizations within a realm. When no domains are specified, organization members will not be validated against domain restrictions during authentication and profile validation. Description:: A free-text field to describe the organization. diff --git a/js/apps/admin-ui/src/organizations/OrganizationForm.tsx b/js/apps/admin-ui/src/organizations/OrganizationForm.tsx index 7ae8f607379..a3f71c55529 100644 --- a/js/apps/admin-ui/src/organizations/OrganizationForm.tsx +++ b/js/apps/admin-ui/src/organizations/OrganizationForm.tsx @@ -68,14 +68,12 @@ export const OrganizationForm = ({ fieldLabelId="domain" /> } - isRequired > {errors?.["domains"]?.message && ( diff --git a/model/jpa/src/main/java/org/keycloak/organization/jpa/JpaOrganizationProvider.java b/model/jpa/src/main/java/org/keycloak/organization/jpa/JpaOrganizationProvider.java index a3d373f20f6..d4e4b5f9939 100644 --- a/model/jpa/src/main/java/org/keycloak/organization/jpa/JpaOrganizationProvider.java +++ b/model/jpa/src/main/java/org/keycloak/organization/jpa/JpaOrganizationProvider.java @@ -42,6 +42,7 @@ import jakarta.persistence.criteria.CriteriaBuilder; import jakarta.persistence.criteria.CriteriaQuery; import jakarta.persistence.criteria.From; import jakarta.persistence.criteria.Join; +import jakarta.persistence.criteria.JoinType; import jakarta.persistence.criteria.Predicate; import jakarta.persistence.criteria.Root; import org.keycloak.connections.jpa.JpaConnectionProvider; @@ -302,26 +303,28 @@ public class JpaOrganizationProvider implements OrganizationProvider { private Predicate buildStringSearchPredicate(CriteriaBuilder builder, CriteriaQuery query, Root org, String search, Boolean exact) { - Root domain = query.from(OrganizationDomainEntity.class); - List predicates = new ArrayList<>(); RealmModel realm = getRealm(); - predicates.add(builder.equal(org.get("realmId"), realm.getId())); - predicates.add(builder.equal(org.get("id"), domain.get("organization").get("id"))); + Predicate realmPredicate = builder.equal(org.get("realmId"), realm.getId()); + if (StringUtil.isBlank(search)) { + return realmPredicate; + } + + predicates.add(realmPredicate); + + Join domain = org.join("domains", JoinType.LEFT); Predicate namePredicate; Predicate domainPredicate; - if (StringUtil.isBlank(search)) { - namePredicate = builder.conjunction(); // constant true - domainPredicate = builder.conjunction(); - } else if (Boolean.TRUE.equals(exact)) { + if (Boolean.TRUE.equals(exact)) { namePredicate = builder.equal(org.get("name"), search); domainPredicate = builder.equal(domain.get("name"), search); } else { namePredicate = builder.like(builder.lower(org.get("name")), "%" + search.toLowerCase() + "%"); domainPredicate = builder.like(domain.get("name"), "%" + search.toLowerCase() + "%"); } + predicates.add(builder.or(namePredicate, domainPredicate)); return builder.and(predicates.toArray(new Predicate[0])); diff --git a/model/jpa/src/main/java/org/keycloak/organization/jpa/OrganizationAdapter.java b/model/jpa/src/main/java/org/keycloak/organization/jpa/OrganizationAdapter.java index d150d2738d1..7f47b014b6a 100644 --- a/model/jpa/src/main/java/org/keycloak/organization/jpa/OrganizationAdapter.java +++ b/model/jpa/src/main/java/org/keycloak/organization/jpa/OrganizationAdapter.java @@ -175,8 +175,8 @@ public final class OrganizationAdapter implements OrganizationModel, JpaModel domains) { - if (domains == null || domains.isEmpty()) { - throw new ModelValidationException("You must provide at least one domain"); + if (domains == null) { + return; } Map modelMap = domains.stream() diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationTest.java index 09bc5885b04..bb694f56c6f 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationTest.java @@ -21,6 +21,8 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.empty; import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.greaterThan; +import static org.hamcrest.Matchers.greaterThanOrEqualTo; import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.not; @@ -30,6 +32,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; import java.util.ArrayList; @@ -39,6 +42,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Optional; import java.util.stream.Collectors; import jakarta.ws.rs.NotFoundException; @@ -466,6 +470,69 @@ public class OrganizationTest extends AbstractOrganizationTest { assertNotNull(existing.getDomain("acme.com")); } + @Test + public void testWithoutDomains() { + // test create organization without any domains + OrganizationRepresentation orgWithoutDomains = new OrganizationRepresentation(); + orgWithoutDomains.setName("no-domain-org"); + orgWithoutDomains.setAlias("no-domain-org"); + orgWithoutDomains.setDescription("Organization without domains"); + + String orgWithoutDomainsId; + try (Response response = testRealm().organizations().create(orgWithoutDomains)) { + assertEquals(Status.CREATED.getStatusCode(), response.getStatus()); + orgWithoutDomainsId = ApiUtil.getCreatedId(response); + } + + OrganizationRepresentation created = testRealm().organizations().get(orgWithoutDomainsId).toRepresentation(); + assertEquals("no-domain-org", created.getName()); + assertEquals("no-domain-org", created.getAlias()); + assertThat(created.getDomains() == null || created.getDomains().isEmpty(), is(true)); + + // verify that the organization can be retrieved + OrganizationRepresentation orgWithDomains = createRepresentation("org-with-domains", "example.com"); + String orgWithDomainsId; + try (Response response = testRealm().organizations().create(orgWithDomains)) { + assertEquals(Status.CREATED.getStatusCode(), response.getStatus()); + orgWithDomainsId = ApiUtil.getCreatedId(response); + } + + try { + List allOrgs = testRealm().organizations().list(-1, -1); + assertThat(allOrgs.size(), greaterThanOrEqualTo(2)); + + Optional foundOrgWithDomains = allOrgs.stream() + .filter(org -> org.getId().equals(orgWithDomainsId)) + .findFirst(); + Optional foundOrgWithoutDomains = allOrgs.stream() + .filter(org -> org.getId().equals(orgWithoutDomainsId)) + .findFirst(); + + assertTrue("Organization with domains should be in the list", foundOrgWithDomains.isPresent()); + assertTrue("Organization without domains should be in the list", foundOrgWithoutDomains.isPresent()); + + assertThat("Organization with domains should have domains", + foundOrgWithDomains.get().getDomains(), is(notNullValue())); + assertThat("Organization with domains should have at least one domain", + foundOrgWithDomains.get().getDomains().size(), greaterThan(0)); + + assertThat("Organization without domains should have no domains", + foundOrgWithoutDomains.get().getDomains() == null || + foundOrgWithoutDomains.get().getDomains().isEmpty(), is(true)); + + List search = testRealm().organizations().search("with-domains", false, -1, -1); + + assertThat(search, hasSize(1)); + + search = testRealm().organizations().search("no-domain", false, -1, -1); + + assertThat(search, hasSize(1)); + } finally { + testRealm().organizations().get(orgWithDomainsId).delete().close(); + testRealm().organizations().get(orgWithoutDomainsId).delete().close(); + } + } + @Test public void testFilterEmptyDomain() { //org should be created with only one domain