From 7fc2269ba539be309261aaf2166384f535c3392c Mon Sep 17 00:00:00 2001 From: Pedro Igor Date: Thu, 14 Mar 2024 18:13:14 -0300 Subject: [PATCH] The bare minimum implementation for organization Signed-off-by: Pedro Igor Co-authored-by: vramik --- .../idm/OrganizationRepresentation.java | 59 +++++++ .../client/resource/OrganizationResource.java | 41 +++++ .../resource/OrganizationsResource.java | 44 ++++++ .../admin/client/resource/RealmResource.java | 3 + .../jpa/entities/OrganizationEntity.java | 90 +++++++++++ .../jpa/JpaOrganizationProvider.java | 128 +++++++++++++++ .../jpa/JpaOrganizationProviderFactory.java | 64 ++++++++ .../organization/jpa/OrganizationAdapter.java | 55 +++++++ .../META-INF/jpa-changelog-25.0.0.xml | 37 +++++ .../META-INF/jpa-changelog-master.xml | 1 + ...k.organization.OrganizationProviderFactory | 18 +++ .../main/resources/default-persistence.xml | 3 + .../organization/OrganizationProvider.java | 6 +- .../OrganizationProviderFactory.java | 11 +- .../admin/resource/OrganizationResource.java | 149 ++++++++++++++++++ .../resource/OrganizationResourceFactory.java | 62 ++++++++ .../OrganizationResourceProvider.java | 37 +++++ ...dmin.ext.AdminRealmResourceProviderFactory | 17 ++ .../organization/admin/OrganizationTest.java | 120 ++++++++++++++ .../tests/base/testsuites/base-suite | 1 + 20 files changed, 943 insertions(+), 3 deletions(-) create mode 100644 core/src/main/java/org/keycloak/representations/idm/OrganizationRepresentation.java create mode 100644 integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/OrganizationResource.java create mode 100644 integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/OrganizationsResource.java create mode 100644 model/jpa/src/main/java/org/keycloak/models/jpa/entities/OrganizationEntity.java create mode 100644 model/jpa/src/main/java/org/keycloak/organization/jpa/JpaOrganizationProvider.java create mode 100644 model/jpa/src/main/java/org/keycloak/organization/jpa/JpaOrganizationProviderFactory.java create mode 100644 model/jpa/src/main/java/org/keycloak/organization/jpa/OrganizationAdapter.java create mode 100644 model/jpa/src/main/resources/META-INF/jpa-changelog-25.0.0.xml create mode 100644 model/jpa/src/main/resources/META-INF/services/org.keycloak.organization.OrganizationProviderFactory rename {server-spi => server-spi-private}/src/main/java/org/keycloak/organization/OrganizationProvider.java (95%) rename {server-spi => server-spi-private}/src/main/java/org/keycloak/organization/OrganizationProviderFactory.java (68%) create mode 100644 services/src/main/java/org/keycloak/organization/admin/resource/OrganizationResource.java create mode 100644 services/src/main/java/org/keycloak/organization/admin/resource/OrganizationResourceFactory.java create mode 100644 services/src/main/java/org/keycloak/organization/admin/resource/OrganizationResourceProvider.java create mode 100644 services/src/main/resources/META-INF/services/org.keycloak.services.resources.admin.ext.AdminRealmResourceProviderFactory create mode 100755 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationTest.java diff --git a/core/src/main/java/org/keycloak/representations/idm/OrganizationRepresentation.java b/core/src/main/java/org/keycloak/representations/idm/OrganizationRepresentation.java new file mode 100644 index 0000000000..352976faea --- /dev/null +++ b/core/src/main/java/org/keycloak/representations/idm/OrganizationRepresentation.java @@ -0,0 +1,59 @@ +/* + * Copyright 2024 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * 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. + */ + +package org.keycloak.representations.idm; + +public class OrganizationRepresentation { + + private String id; + private String name; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public void setName(String name) { + this.name = name; + } + + public String getName() { + return name; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null) return false; + if (!(o instanceof OrganizationRepresentation)) return false; + + OrganizationRepresentation that = (OrganizationRepresentation) o; + + return id != null && id.equals(that.getId()); + } + + @Override + public int hashCode() { + if (id == null) { + return super.hashCode(); + } + return id.hashCode(); + } +} diff --git a/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/OrganizationResource.java b/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/OrganizationResource.java new file mode 100644 index 0000000000..0c824afefe --- /dev/null +++ b/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/OrganizationResource.java @@ -0,0 +1,41 @@ +/* + * Copyright 2024 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * 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. + */ + +package org.keycloak.admin.client.resource; + +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import org.keycloak.representations.idm.OrganizationRepresentation; + +public interface OrganizationResource { + + @GET + @Produces(MediaType.APPLICATION_JSON) + OrganizationRepresentation toRepresentation(); + + @PUT + @Consumes(MediaType.APPLICATION_JSON) + Response update(OrganizationRepresentation organization); + + @DELETE + Response delete(); +} diff --git a/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/OrganizationsResource.java b/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/OrganizationsResource.java new file mode 100644 index 0000000000..f6972ce9f9 --- /dev/null +++ b/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/OrganizationsResource.java @@ -0,0 +1,44 @@ +/* + * Copyright 2024 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * 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. + */ + +package org.keycloak.admin.client.resource; + +import java.util.List; + +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import org.keycloak.representations.idm.OrganizationRepresentation; + +public interface OrganizationsResource { + + @POST + @Consumes(MediaType.APPLICATION_JSON) + Response create(OrganizationRepresentation organization); + + @Path("{id}") + OrganizationResource get(@PathParam("id") String id); + + @GET + @Produces(MediaType.APPLICATION_JSON) + List getAll(); +} diff --git a/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/RealmResource.java b/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/RealmResource.java index 40293fb70b..a60ce1b5cf 100644 --- a/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/RealmResource.java +++ b/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/RealmResource.java @@ -288,4 +288,7 @@ public interface RealmResource { @Path("client-policies/profiles") ClientPoliciesProfilesResource clientPoliciesProfilesResource(); + + @Path("organizations") + OrganizationsResource organizations(); } diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/entities/OrganizationEntity.java b/model/jpa/src/main/java/org/keycloak/models/jpa/entities/OrganizationEntity.java new file mode 100644 index 0000000000..74f633ca5a --- /dev/null +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/entities/OrganizationEntity.java @@ -0,0 +1,90 @@ +/* + * Copyright 2024 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * 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. + */ + +package org.keycloak.models.jpa.entities; + +import jakarta.persistence.Access; +import jakarta.persistence.AccessType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.NamedQueries; +import jakarta.persistence.NamedQuery; +import jakarta.persistence.Table; + +@Table(name="ORGANIZATION") +@Entity +@NamedQueries({ + @NamedQuery(name="deleteByRealm", query="delete from OrganizationEntity o where o.realmId = :realmId"), + @NamedQuery(name="getByRealm", query="select o.id from OrganizationEntity o where o.realmId = :realmId") +}) +public class OrganizationEntity { + + @Id + @Column(name="ID", length = 36) + @Access(AccessType.PROPERTY) + protected String id; + + @Column(name = "REALM_ID") + private String realmId; + + @Column(name="NAME") + protected String name; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getRealmId() { + return realmId; + } + + public void setRealmId(String realm) { + this.realmId = realm; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null) return false; + if (!(o instanceof OrganizationEntity)) return false; + + OrganizationEntity that = (OrganizationEntity) o; + + return id != null && id.equals(that.getId()); + } + + @Override + public int hashCode() { + if (id == null) { + return super.hashCode(); + } + return id.hashCode(); + } +} 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 new file mode 100644 index 0000000000..6801b97c8f --- /dev/null +++ b/model/jpa/src/main/java/org/keycloak/organization/jpa/JpaOrganizationProvider.java @@ -0,0 +1,128 @@ +/* + * Copyright 2024 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * 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. + */ + +package org.keycloak.organization.jpa; + +import static org.keycloak.utils.StreamsUtil.closing; + +import java.util.stream.Stream; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.Query; +import jakarta.persistence.TypedQuery; +import org.keycloak.connections.jpa.JpaConnectionProvider; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.OrganizationModel; +import org.keycloak.models.RealmModel; +import org.keycloak.models.jpa.entities.OrganizationEntity; +import org.keycloak.models.utils.KeycloakModelUtils; +import org.keycloak.organization.OrganizationProvider; + +public class JpaOrganizationProvider implements OrganizationProvider { + + private final EntityManager em; + + public JpaOrganizationProvider(KeycloakSession session) { + JpaConnectionProvider jpaProvider = session.getProvider(JpaConnectionProvider.class); + this.em = jpaProvider.getEntityManager(); + } + + @Override + public OrganizationModel createOrganization(RealmModel realm, String name) { + throwExceptionIfRealmIsNull(realm); + OrganizationEntity entity = new OrganizationEntity(); + + entity.setId(KeycloakModelUtils.generateId()); + entity.setRealmId(realm.getId()); + entity.setName(name); + + em.persist(entity); + + return new OrganizationAdapter(entity); + } + + @Override + public boolean removeOrganization(RealmModel realm, OrganizationModel organization) { + throwExceptionIfRealmIsNull(realm); + throwExceptionIfOrganizationIsNull(organization); + OrganizationAdapter toRemove = getAdapter(realm, organization.getId()); + throwExceptionIfOrganizationIsNull(toRemove); + + if (!toRemove.getRealm().equals(realm.getId())) { + throw new IllegalArgumentException("Organization [" + organization.getId() + " does not belong to realm [" + realm.getId() + "]"); + } + + em.remove(toRemove.getEntity()); + + return true; + } + + @Override + public void removeOrganizations(RealmModel realm) { + throwExceptionIfRealmIsNull(realm); + Query query = em.createNamedQuery("deleteByRealm"); + + query.setParameter("realmId", realm.getId()); + + query.executeUpdate(); + } + + @Override + public OrganizationModel getOrganizationById(RealmModel realm, String id) { + return getAdapter(realm, id); + } + + @Override + public Stream getOrganizationsStream(RealmModel realm) { + TypedQuery query = em.createNamedQuery("getByRealm", String.class); + + query.setParameter("realmId", realm.getId()); + + return closing(query.getResultStream().map(id -> getAdapter(realm, id))); + } + + @Override + public void close() { + + } + + private OrganizationAdapter getAdapter(RealmModel realm, String id) { + OrganizationEntity entity = em.find(OrganizationEntity.class, id); + + if (entity == null) { + return null; + } + + if (!realm.getId().equals(entity.getRealmId())) { + return null; + } + + return new OrganizationAdapter(entity); + } + + private void throwExceptionIfOrganizationIsNull(OrganizationModel organization) { + if (organization == null) { + throw new IllegalArgumentException("organization can not be null"); + } + } + + private void throwExceptionIfRealmIsNull(RealmModel realm) { + if (realm == null) { + throw new IllegalArgumentException("realm can not be null"); + } + } +} diff --git a/model/jpa/src/main/java/org/keycloak/organization/jpa/JpaOrganizationProviderFactory.java b/model/jpa/src/main/java/org/keycloak/organization/jpa/JpaOrganizationProviderFactory.java new file mode 100644 index 0000000000..ff0e014339 --- /dev/null +++ b/model/jpa/src/main/java/org/keycloak/organization/jpa/JpaOrganizationProviderFactory.java @@ -0,0 +1,64 @@ +/* + * Copyright 2024 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * 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. + */ + +package org.keycloak.organization.jpa; + +import org.keycloak.Config.Scope; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.models.RealmModel; +import org.keycloak.models.RealmModel.RealmRemovedEvent; +import org.keycloak.organization.OrganizationProvider; +import org.keycloak.organization.OrganizationProviderFactory; +import org.keycloak.provider.ProviderEvent; + +public class JpaOrganizationProviderFactory implements OrganizationProviderFactory { + + @Override + public OrganizationProvider create(KeycloakSession session) { + return new JpaOrganizationProvider(session); + } + + @Override + public void init(Scope config) { + + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + factory.register(this::handleRealmRemovedEvent); + } + + @Override + public void close() { + + } + + @Override + public String getId() { + return "jpa"; + } + + private void handleRealmRemovedEvent(ProviderEvent event) { + if (event instanceof RealmRemovedEvent) { + KeycloakSession session = ((RealmRemovedEvent) event).getKeycloakSession(); + OrganizationProvider provider = session.getProvider(OrganizationProvider.class); + RealmModel realm = ((RealmRemovedEvent) event).getRealm(); + provider.removeOrganizations(realm); + } + } +} 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 new file mode 100644 index 0000000000..9134eb15d3 --- /dev/null +++ b/model/jpa/src/main/java/org/keycloak/organization/jpa/OrganizationAdapter.java @@ -0,0 +1,55 @@ +/* + * Copyright 2024 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * 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. + */ + +package org.keycloak.organization.jpa; + +import org.keycloak.models.OrganizationModel; +import org.keycloak.models.jpa.JpaModel; +import org.keycloak.models.jpa.entities.OrganizationEntity; + +public class OrganizationAdapter implements OrganizationModel, JpaModel { + + private final OrganizationEntity entity; + + public OrganizationAdapter(OrganizationEntity entity) { + this.entity = entity; + } + + @Override + public String getId() { + return entity.getId(); + } + + String getRealm() { + return entity.getRealmId(); + } + + @Override + public void setName(String name) { + entity.setName(name); + } + + @Override + public String getName() { + return entity.getName(); + } + + @Override + public OrganizationEntity getEntity() { + return entity; + } +} diff --git a/model/jpa/src/main/resources/META-INF/jpa-changelog-25.0.0.xml b/model/jpa/src/main/resources/META-INF/jpa-changelog-25.0.0.xml new file mode 100644 index 0000000000..27a4738742 --- /dev/null +++ b/model/jpa/src/main/resources/META-INF/jpa-changelog-25.0.0.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/model/jpa/src/main/resources/META-INF/jpa-changelog-master.xml b/model/jpa/src/main/resources/META-INF/jpa-changelog-master.xml index a043d6a152..b4998f0bbd 100755 --- a/model/jpa/src/main/resources/META-INF/jpa-changelog-master.xml +++ b/model/jpa/src/main/resources/META-INF/jpa-changelog-master.xml @@ -81,5 +81,6 @@ + diff --git a/model/jpa/src/main/resources/META-INF/services/org.keycloak.organization.OrganizationProviderFactory b/model/jpa/src/main/resources/META-INF/services/org.keycloak.organization.OrganizationProviderFactory new file mode 100644 index 0000000000..622165acfa --- /dev/null +++ b/model/jpa/src/main/resources/META-INF/services/org.keycloak.organization.OrganizationProviderFactory @@ -0,0 +1,18 @@ +# +# Copyright 2024 Red Hat, Inc. and/or its affiliates +# and other contributors as indicated by the @author tags. +# +# 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. +# + +org.keycloak.organization.jpa.JpaOrganizationProviderFactory \ No newline at end of file diff --git a/model/jpa/src/main/resources/default-persistence.xml b/model/jpa/src/main/resources/default-persistence.xml index 7acc6a92d9..bcc0da0ac1 100644 --- a/model/jpa/src/main/resources/default-persistence.xml +++ b/model/jpa/src/main/resources/default-persistence.xml @@ -84,6 +84,9 @@ org.keycloak.storage.jpa.entity.FederatedUserRequiredActionEntity org.keycloak.storage.jpa.entity.FederatedUserRoleMappingEntity + + org.keycloak.models.jpa.entities.OrganizationEntity + true diff --git a/server-spi/src/main/java/org/keycloak/organization/OrganizationProvider.java b/server-spi-private/src/main/java/org/keycloak/organization/OrganizationProvider.java similarity index 95% rename from server-spi/src/main/java/org/keycloak/organization/OrganizationProvider.java rename to server-spi-private/src/main/java/org/keycloak/organization/OrganizationProvider.java index 4630915306..52c83d6f7e 100644 --- a/server-spi/src/main/java/org/keycloak/organization/OrganizationProvider.java +++ b/server-spi-private/src/main/java/org/keycloak/organization/OrganizationProvider.java @@ -17,6 +17,7 @@ package org.keycloak.organization; import java.util.stream.Stream; + import org.keycloak.models.ModelDuplicateException; import org.keycloak.models.OrganizationModel; import org.keycloak.models.RealmModel; @@ -29,11 +30,13 @@ public interface OrganizationProvider extends Provider { * The internal ID of the organization will be created automatically. * @param realm Realm owning this organization. * @param name String name of the organization. - * @throws ModelDuplicateException If there is already an organization with the given name + * @throws ModelDuplicateException If there is already an organization with the given name * @return Model of the created organization. */ OrganizationModel createOrganization(RealmModel realm, String name); + OrganizationModel getOrganizationById(RealmModel realm, String id); + /** * Removes the given organization from the given realm. * @@ -55,5 +58,4 @@ public interface OrganizationProvider extends Provider { * @return Stream of the organizations. Never returns {@code null}. */ Stream getOrganizationsStream(RealmModel realm); - } diff --git a/server-spi/src/main/java/org/keycloak/organization/OrganizationProviderFactory.java b/server-spi-private/src/main/java/org/keycloak/organization/OrganizationProviderFactory.java similarity index 68% rename from server-spi/src/main/java/org/keycloak/organization/OrganizationProviderFactory.java rename to server-spi-private/src/main/java/org/keycloak/organization/OrganizationProviderFactory.java index 749b9facd1..e44c1b62ad 100644 --- a/server-spi/src/main/java/org/keycloak/organization/OrganizationProviderFactory.java +++ b/server-spi-private/src/main/java/org/keycloak/organization/OrganizationProviderFactory.java @@ -16,7 +16,16 @@ */ package org.keycloak.organization; +import org.keycloak.Config.Scope; +import org.keycloak.common.Profile; +import org.keycloak.common.Profile.Feature; +import org.keycloak.provider.EnvironmentDependentProviderFactory; import org.keycloak.provider.ProviderFactory; -public interface OrganizationProviderFactory extends ProviderFactory { +public interface OrganizationProviderFactory extends ProviderFactory, EnvironmentDependentProviderFactory { + + @Override + default boolean isSupported(Scope config) { + return Profile.isFeatureEnabled(Feature.ORGANIZATION); + } } diff --git a/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationResource.java b/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationResource.java new file mode 100644 index 0000000000..f978f011d3 --- /dev/null +++ b/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationResource.java @@ -0,0 +1,149 @@ +/* + * Copyright 2024 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * 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. + */ + +package org.keycloak.organization.admin.resource; + +import java.util.stream.Stream; + +import jakarta.ws.rs.BadRequestException; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.NotFoundException; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.ext.Provider; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.OrganizationModel; +import org.keycloak.models.RealmModel; +import org.keycloak.organization.OrganizationProvider; +import org.keycloak.representations.idm.OrganizationRepresentation; +import org.keycloak.utils.StringUtil; + +@Provider +public class OrganizationResource { + + private final KeycloakSession session; + private final OrganizationProvider provider; + + public OrganizationResource() { + // needed for registering to the JAX-RS stack + this(null); + } + + public OrganizationResource(KeycloakSession session) { + this.session = session; + this.provider = session == null ? null : session.getProvider(OrganizationProvider.class); + } + + @POST + @Consumes(MediaType.APPLICATION_JSON) + public Response create(OrganizationRepresentation organization) { + if (organization == null) { + throw new BadRequestException(); + } + + RealmModel realm = session.getContext().getRealm(); + OrganizationModel model = provider.createOrganization(realm, organization.getName()); + + return Response.created(session.getContext().getUri().getAbsolutePathBuilder().path(model.getId()).build()).build(); + } + + @GET + @Produces(MediaType.APPLICATION_JSON) + public Stream get() { + return provider.getOrganizationsStream(session.getContext().getRealm()).map(this::toRepresentation); + } + + @Path("{id}") + @GET + @Produces(MediaType.APPLICATION_JSON) + public OrganizationRepresentation get(@PathParam("id") String id) { + if (StringUtil.isBlank(id)) { + throw new BadRequestException(); + } + + return toRepresentation(getOrganization(session.getContext().getRealm(), id)); + } + + @Path("{id}") + @DELETE + public Response delete(@PathParam("id") String id) { + if (StringUtil.isBlank(id)) { + throw new BadRequestException(); + } + + RealmModel realm = session.getContext().getRealm(); + provider.removeOrganization(realm, getOrganization(realm, id)); + + return Response.noContent().build(); + } + + @Path("{id}") + @PUT + @Consumes(MediaType.APPLICATION_JSON) + public Response update(@PathParam("id") String id, OrganizationRepresentation organization) { + RealmModel realm = session.getContext().getRealm(); + OrganizationModel model = getOrganization(realm, id); + + toModel(organization, model); + + return Response.noContent().build(); + } + + private OrganizationModel getOrganization(RealmModel realm, String id) { + if (id == null) { + throw new BadRequestException(); + } + + OrganizationModel model = provider.getOrganizationById(realm, id); + + if (model == null) { + throw new NotFoundException(); + } + + return model; + } + + private OrganizationRepresentation toRepresentation(OrganizationModel model) { + if (model == null) { + return null; + } + + OrganizationRepresentation rep = new OrganizationRepresentation(); + + rep.setId(model.getId()); + rep.setName(model.getName()); + + return rep; + } + + private OrganizationModel toModel(OrganizationRepresentation rep, OrganizationModel model) { + if (rep == null) { + return null; + } + + model.setName(rep.getName()); + + return model; + } +} diff --git a/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationResourceFactory.java b/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationResourceFactory.java new file mode 100644 index 0000000000..4d99e87ede --- /dev/null +++ b/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationResourceFactory.java @@ -0,0 +1,62 @@ +/* + * Copyright 2024 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * 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. + */ + +package org.keycloak.organization.admin.resource; + +import org.keycloak.Config.Scope; +import org.keycloak.common.Profile; +import org.keycloak.common.Profile.Feature; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.provider.EnvironmentDependentProviderFactory; +import org.keycloak.services.resources.admin.ext.AdminRealmResourceProvider; +import org.keycloak.services.resources.admin.ext.AdminRealmResourceProviderFactory; + +public class OrganizationResourceFactory implements AdminRealmResourceProviderFactory, EnvironmentDependentProviderFactory { + + private OrganizationResourceProvider PROVIDER_INSTANCE; + + @Override + public AdminRealmResourceProvider create(KeycloakSession session) { + return PROVIDER_INSTANCE; + } + + @Override + public void init(Scope config) { + + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + PROVIDER_INSTANCE = new OrganizationResourceProvider(); + } + + @Override + public void close() { + + } + + @Override + public String getId() { + return "organizations"; + } + + @Override + public boolean isSupported(Scope config) { + return Profile.isFeatureEnabled(Feature.ORGANIZATION); + } +} diff --git a/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationResourceProvider.java b/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationResourceProvider.java new file mode 100644 index 0000000000..86c96e248f --- /dev/null +++ b/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationResourceProvider.java @@ -0,0 +1,37 @@ +/* + * Copyright 2024 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * 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. + */ + +package org.keycloak.organization.admin.resource; + +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.services.resources.admin.AdminEventBuilder; +import org.keycloak.services.resources.admin.ext.AdminRealmResourceProvider; +import org.keycloak.services.resources.admin.permissions.AdminPermissionEvaluator; + +public class OrganizationResourceProvider implements AdminRealmResourceProvider { + + @Override + public Object getResource(KeycloakSession session, RealmModel realm, AdminPermissionEvaluator auth, AdminEventBuilder adminEvent) { + return new OrganizationResource(session); + } + + @Override + public void close() { + + } +} diff --git a/services/src/main/resources/META-INF/services/org.keycloak.services.resources.admin.ext.AdminRealmResourceProviderFactory b/services/src/main/resources/META-INF/services/org.keycloak.services.resources.admin.ext.AdminRealmResourceProviderFactory new file mode 100644 index 0000000000..53bc20edf4 --- /dev/null +++ b/services/src/main/resources/META-INF/services/org.keycloak.services.resources.admin.ext.AdminRealmResourceProviderFactory @@ -0,0 +1,17 @@ +# +# Copyright 2024 Red Hat, Inc. and/or its affiliates +# and other contributors as indicated by the @author tags. +# +# 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. +# +org.keycloak.organization.admin.resource.OrganizationResourceFactory \ No newline at end of file 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 new file mode 100755 index 0000000000..247c2046f0 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationTest.java @@ -0,0 +1,120 @@ +/* + * Copyright 2024 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * 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. + */ + +package org.keycloak.testsuite.organization.admin; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.fail; + +import java.util.ArrayList; +import java.util.List; + +import jakarta.ws.rs.NotFoundException; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Response.Status; +import org.hamcrest.MatcherAssert; +import org.hamcrest.Matchers; +import org.junit.Test; +import org.keycloak.admin.client.resource.OrganizationResource; +import org.keycloak.common.Profile.Feature; +import org.keycloak.representations.idm.OrganizationRepresentation; +import org.keycloak.testsuite.admin.AbstractAdminTest; +import org.keycloak.testsuite.admin.ApiUtil; +import org.keycloak.testsuite.arquillian.annotation.EnableFeature; + +@EnableFeature(Feature.ORGANIZATION) +public class OrganizationTest extends AbstractAdminTest { + + @Test + public void testUpdate() { + OrganizationRepresentation expected = createRepresentation(); + + assertEquals("neworg", expected.getName()); + expected.setName("acme"); + + OrganizationResource organization = testRealm().organizations().get(expected.getId()); + + try (Response response = organization.update(expected)) { + assertEquals(Status.NO_CONTENT.getStatusCode(), response.getStatus()); + } + + OrganizationRepresentation existing = organization.toRepresentation(); + assertEquals(expected.getId(), existing.getId()); + assertEquals(expected.getName(), existing.getName()); + } + + @Test + public void testGet() { + OrganizationRepresentation expected = createRepresentation(); + OrganizationRepresentation existing = testRealm().organizations().get(expected.getId()).toRepresentation(); + assertNotNull(existing); + assertEquals(expected.getId(), existing.getId()); + assertEquals(expected.getName(), existing.getName()); + } + + @Test + public void testGetAll() { + List expected = new ArrayList<>(); + + for (int i = 0; i < 5; i++) { + expected.add(createRepresentation("org-" + i)); + } + + List existing = testRealm().organizations().getAll(); + assertFalse(existing.isEmpty()); + MatcherAssert.assertThat(expected, Matchers.containsInAnyOrder(existing.toArray())); + } + + @Test + public void testDelete() { + OrganizationRepresentation expected = createRepresentation(); + OrganizationResource organization = testRealm().organizations().get(expected.getId()); + + try (Response response = organization.delete()) { + assertEquals(Status.NO_CONTENT.getStatusCode(), response.getStatus()); + } + + try { + organization.toRepresentation(); + fail("should be deleted"); + } catch (NotFoundException ignore) {} + } + + private OrganizationRepresentation createRepresentation() { + return createRepresentation("neworg"); + } + + private OrganizationRepresentation createRepresentation(String name) { + OrganizationRepresentation org = new OrganizationRepresentation(); + + org.setName(name); + + String id; + + try (Response response = testRealm().organizations().create(org)) { + assertEquals(Status.CREATED.getStatusCode(), response.getStatus()); + id = ApiUtil.getCreatedId(response); + } + + org.setId(id); + getCleanup().addCleanup(() -> testRealm().organizations().get(id).delete().close()); + + return org; + } +} diff --git a/testsuite/integration-arquillian/tests/base/testsuites/base-suite b/testsuite/integration-arquillian/tests/base/testsuites/base-suite index 90cfda0b05..bbd0e8ff4a 100644 --- a/testsuite/integration-arquillian/tests/base/testsuites/base-suite +++ b/testsuite/integration-arquillian/tests/base/testsuites/base-suite @@ -29,6 +29,7 @@ model,6 oauth,6 oid4vc,6 oidc,6 +organization,3 policy,6 providers,4 runonserver,6