diff --git a/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/OrganizationIdentityProviderResource.java b/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/OrganizationIdentityProviderResource.java new file mode 100644 index 0000000000..68c153b5c1 --- /dev/null +++ b/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/OrganizationIdentityProviderResource.java @@ -0,0 +1,46 @@ +/* + * 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.POST; +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.IdentityProviderRepresentation; + +public interface OrganizationIdentityProviderResource { + + @POST + @Consumes(MediaType.APPLICATION_JSON) + Response create(IdentityProviderRepresentation idpRepresentation); + + @GET + @Produces(MediaType.APPLICATION_JSON) + IdentityProviderRepresentation toRepresentation(); + + @PUT + @Consumes(MediaType.APPLICATION_JSON) + Response update(IdentityProviderRepresentation idpRepresentation); + + @DELETE + Response delete(); +} 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 index dc1d13cf56..d6c3b12432 100644 --- 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 @@ -42,4 +42,7 @@ public interface OrganizationResource { @Path("members") OrganizationMembersResource members(); + + @Path("identity-provider") + OrganizationIdentityProviderResource identityProvider(); } diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/entities/IdentityProviderEntity.java b/model/jpa/src/main/java/org/keycloak/models/jpa/entities/IdentityProviderEntity.java index 82032b7552..4b4c89401a 100755 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/entities/IdentityProviderEntity.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/entities/IdentityProviderEntity.java @@ -28,8 +28,6 @@ import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.MapKeyColumn; -import jakarta.persistence.NamedQueries; -import jakarta.persistence.NamedQuery; import jakarta.persistence.Table; import java.util.Map; @@ -38,9 +36,6 @@ import java.util.Map; */ @Entity @Table(name="IDENTITY_PROVIDER") -@NamedQueries({ - @NamedQuery(name="findIdentityProviderByAlias", query="select identityProvider from IdentityProviderEntity identityProvider where identityProvider.alias = :alias") -}) public class IdentityProviderEntity { @Id 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 index ebf7d03cc3..566f5a5acb 100644 --- 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 @@ -34,9 +34,12 @@ import jakarta.persistence.Table; public class OrganizationEntity { @Id - @Column(name="ID", length = 36) + @Column(name = "ID", length = 36) @Access(AccessType.PROPERTY) - protected String id; + private String id; + + @Column(name = "NAME") + private String name; @Column(name = "REALM_ID") private String realmId; @@ -44,8 +47,8 @@ public class OrganizationEntity { @Column(name = "GROUP_ID") private String groupId; - @Column(name="NAME") - protected String name; + @Column(name = "IPD_ALIAS") + private String idpAlias; public String getId() { return id; @@ -55,6 +58,10 @@ public class OrganizationEntity { this.id = id; } + public void setName(String name) { + this.name = name; + } + public String getRealmId() { return realmId; } @@ -75,8 +82,12 @@ public class OrganizationEntity { return name; } - public void setName(String name) { - this.name = name; + public String getIdpAlias() { + return idpAlias; + } + + public void setIdpAlias(String idpAlias) { + this.idpAlias = idpAlias; } @Override 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 1e0ebee2a9..902a7e3f3d 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 @@ -27,7 +27,9 @@ import jakarta.persistence.TypedQuery; import org.keycloak.connections.jpa.JpaConnectionProvider; import org.keycloak.models.GroupModel; import org.keycloak.models.GroupProvider; +import org.keycloak.models.IdentityProviderModel; import org.keycloak.models.KeycloakSession; +import org.keycloak.models.ModelDuplicateException; import org.keycloak.models.ModelException; import org.keycloak.models.OrganizationModel; import org.keycloak.models.RealmModel; @@ -71,15 +73,17 @@ public class JpaOrganizationProvider implements OrganizationProvider { @Override public boolean remove(OrganizationModel organization) { + OrganizationEntity entity = getEntity(organization.getId()); + GroupModel group = getOrganizationGroup(organization); //TODO: won't scale, requires a better mechanism for bulk deleting users userProvider.getGroupMembersStream(realm, group).forEach(userModel -> userProvider.removeUser(realm, userModel)); groupProvider.removeGroup(realm, group); - OrganizationAdapter adapter = getAdapter(organization.getId()); + realm.removeIdentityProviderByAlias(entity.getIdpAlias()); - em.remove(adapter.getEntity()); + em.remove(entity); return true; } @@ -92,12 +96,11 @@ public class JpaOrganizationProvider implements OrganizationProvider { @Override public boolean addMember(OrganizationModel organization, UserModel user) { - throwExceptionIfOrganizationIsNull(organization); - if (user == null) { - throw new ModelException("User can not be null"); - } - OrganizationAdapter adapter = getAdapter(organization.getId()); - GroupModel group = groupProvider.getGroupById(realm, adapter.getGroupId()); + throwExceptionIfObjectIsNull(organization, "Organization"); + throwExceptionIfObjectIsNull(user, "User"); + + OrganizationEntity entity = getEntity(organization.getId()); + GroupModel group = groupProvider.getGroupById(realm, entity.getGroupId()); if (user.isMemberOf(group)) { return false; @@ -108,14 +111,15 @@ public class JpaOrganizationProvider implements OrganizationProvider { } user.joinGroup(group); - user.setSingleAttribute(USER_ORGANIZATION_ATTRIBUTE, adapter.getId()); + user.setSingleAttribute(USER_ORGANIZATION_ATTRIBUTE, entity.getId()); return true; } @Override public OrganizationModel getById(String id) { - return getAdapter(id, false); + OrganizationEntity entity = getEntity(id, false); + return entity == null ? null : new OrganizationAdapter(realm, entity); } @Override @@ -129,16 +133,15 @@ public class JpaOrganizationProvider implements OrganizationProvider { @Override public Stream getMembersStream(OrganizationModel organization) { - throwExceptionIfOrganizationIsNull(organization); - OrganizationAdapter adapter = getAdapter(organization.getId()); - GroupModel group = getOrganizationGroup(adapter); + throwExceptionIfObjectIsNull(organization, "Organization"); + GroupModel group = getOrganizationGroup(organization); return userProvider.getGroupMembersStream(realm, group); } @Override public UserModel getMemberById(OrganizationModel organization, String id) { - throwExceptionIfOrganizationIsNull(organization); + throwExceptionIfObjectIsNull(organization, "Organization"); UserModel user = userProvider.getUserById(realm, id); if (user == null) { @@ -156,9 +159,7 @@ public class JpaOrganizationProvider implements OrganizationProvider { @Override public OrganizationModel getByMember(UserModel member) { - if (member == null) { - throw new ModelException("User can not be null"); - } + throwExceptionIfObjectIsNull(member, "User"); String orgId = member.getFirstAttribute(USER_ORGANIZATION_ATTRIBUTE); @@ -169,16 +170,47 @@ public class JpaOrganizationProvider implements OrganizationProvider { return getById(orgId); } + @Override + public boolean addIdentityProvider(OrganizationModel organization, IdentityProviderModel identityProvider) { + throwExceptionIfObjectIsNull(organization, "Organization"); + throwExceptionIfObjectIsNull(identityProvider, "Identity provider"); + + OrganizationEntity organizationEntity = getEntity(organization.getId()); + organizationEntity.setIdpAlias(identityProvider.getAlias()); + return true; + } + + @Override + public IdentityProviderModel getIdentityProvider(OrganizationModel organization) { + throwExceptionIfObjectIsNull(organization, "Organization"); + throwExceptionIfObjectIsNull(organization.getId(), "Organization ID"); + + OrganizationEntity organizationEntity = getEntity(organization.getId()); + // realm and its IDPs are cached + return realm.getIdentityProviderByAlias(organizationEntity.getIdpAlias()); + } + + @Override + public boolean removeIdentityProvider(OrganizationModel organization) { + throwExceptionIfObjectIsNull(organization, "Organization"); + + OrganizationEntity organizationEntity = getEntity(organization.getId()); + organizationEntity.setIdpAlias(null); + return true; + } + @Override public void close() { - } - private OrganizationAdapter getAdapter(String id) { - return getAdapter(id, true); + /** + * @throws ModelException if there is no entity with given {@code id} + */ + private OrganizationEntity getEntity(String id) { + return getEntity(id, true); } - private OrganizationAdapter getAdapter(String id, boolean failIfNotFound) { + private OrganizationEntity getEntity(String id, boolean failIfNotFound) { OrganizationEntity entity = em.find(OrganizationEntity.class, id); if (entity == null) { @@ -192,19 +224,17 @@ public class JpaOrganizationProvider implements OrganizationProvider { throw new ModelException("Organization [" + entity.getId() + " does not belong to realm [" + realm.getId() + "]"); } - return new OrganizationAdapter(realm, entity); + return entity; } - + private GroupModel createOrganizationGroup(String name) { - if (name == null) { - throw new ModelException("name can not be null"); - } + throwExceptionIfObjectIsNull(name, "Name of the group"); String groupName = getCanonicalGroupName(name); GroupModel group = groupProvider.getGroupByName(realm, null, name); if (group != null) { - throw new ModelException("A group with the same name already exist and it is bound to different organization"); + throw new ModelDuplicateException("A group with the same name already exist and it is bound to different organization"); } return groupProvider.createGroup(realm, groupName); @@ -215,21 +245,21 @@ public class JpaOrganizationProvider implements OrganizationProvider { } private GroupModel getOrganizationGroup(OrganizationModel organization) { - throwExceptionIfOrganizationIsNull(organization); - OrganizationAdapter adapter = getAdapter(organization.getId()); + throwExceptionIfObjectIsNull(organization, "Organization"); + OrganizationEntity entity = getEntity(organization.getId()); - GroupModel group = groupProvider.getGroupById(realm, adapter.getGroupId()); + GroupModel group = groupProvider.getGroupById(realm, entity.getGroupId()); if (group == null) { - throw new ModelException("Organization group " + adapter.getGroupId() + " not found"); + throw new ModelException("Organization group " + entity.getGroupId() + " not found"); } return group; } - private void throwExceptionIfOrganizationIsNull(OrganizationModel organization) { - if (organization == null) { - throw new ModelException("organization can not be null"); + private void throwExceptionIfObjectIsNull(Object object, String objectName) { + if (object == null) { + throw new ModelException(String.format("%s cannot be null", objectName)); } } } 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 index e486b83636..6c1c11c208 100644 --- 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 @@ -85,6 +85,7 @@ + diff --git a/server-spi-private/src/main/java/org/keycloak/organization/OrganizationProvider.java b/server-spi-private/src/main/java/org/keycloak/organization/OrganizationProvider.java index 2a10541f13..38f597cc3f 100644 --- a/server-spi-private/src/main/java/org/keycloak/organization/OrganizationProvider.java +++ b/server-spi-private/src/main/java/org/keycloak/organization/OrganizationProvider.java @@ -17,7 +17,7 @@ package org.keycloak.organization; import java.util.stream.Stream; - +import org.keycloak.models.IdentityProviderModel; import org.keycloak.models.ModelDuplicateException; import org.keycloak.models.OrganizationModel; import org.keycloak.models.UserModel; @@ -29,7 +29,7 @@ import org.keycloak.provider.Provider; public interface OrganizationProvider extends Provider { /** - * Creates a new organization with given {@code name} to the given realm. + * Creates a new organization with given {@code name} to the realm. * The internal ID of the organization will be created automatically. * @param name String name of the organization. * @throws ModelDuplicateException If there is already an organization with the given name @@ -41,60 +41,85 @@ public interface OrganizationProvider extends Provider { * Returns a {@link OrganizationModel} by its {@code id}; * * @param id the id of an organization - * @return the organization with the given {@code id} + * @return the organization with the given {@code id} or {@code null} if there is no such an organization. */ OrganizationModel getById(String id); /** - * Removes the given organization from the given realm. + * Removes the given organization from the realm together with the data associated with it, e.g. its members etc. * * @param organization Organization to be removed. - * @return true if the organization was removed, false if group doesn't exist or doesn't belong to the given realm + * @throws ModelException if the organization doesn't exist or doesn't belong to the realm. + * @return {@code true} if the organization was removed, {@code false} otherwise */ boolean remove(OrganizationModel organization); /** - * Removes all organizations from the given realm. + * Removes all organizations from the realm. */ void removeAll(); /** - * Adds the give {@link UserModel} as a member of the given {@link OrganizationModel}. + * Adds the given {@link UserModel} as a member of the given {@link OrganizationModel}. * * @param organization the organization * @param user the user + * @throws ModelException if the {@link UserModel} is member of different organization * @return {@code true} if the user was added as a member. Otherwise, returns {@code false} */ boolean addMember(OrganizationModel organization, UserModel user); /** - * Returns the organizations of the given realm as a stream. + * Returns the organizations of the realm as a stream. * @return Stream of the organizations. Never returns {@code null}. */ Stream getAllStream(); /** - * Returns the members of a given {@code organization}. + * Returns the members of a given {@link OrganizationModel}. * * @param organization the organization - * @return the organization with the given {@code id} + * @return Stream of the members. Never returns {@code null}. */ Stream getMembersStream(OrganizationModel organization); /** - * Returns the member of an {@code organization} by its {@code id}. + * Returns the member of the {@link OrganizationModel} by its {@code id}. * * @param organization the organization * @param id the member id - * @return the organization with the given {@code id} + * @return the member of the {@link OrganizationModel} with the given {@code id} */ UserModel getMemberById(OrganizationModel organization, String id); /** - * Returns the {@link OrganizationModel} that a {@code member} belongs to. + * Returns the {@link OrganizationModel} that the {@code member} belongs to. * * @param member the member of a organization - * @return the organization the {@code member} belongs to + * @return the organization the {@code member} belongs to or {@code null} if the user doesn't belong to any. */ OrganizationModel getByMember(UserModel member); + + /** + * Associate the given {@link IdentityProviderModel} with the given {@link OrganizationModel}. + * + * @param organization the organization + * @param identityProvider the identityProvider + * @return {@code true} if the identityProvider was associated with the organization. Otherwise, returns {@code false} + */ + boolean addIdentityProvider(OrganizationModel organization, IdentityProviderModel identityProvider); + + /** + * @param organization the organization + * @return The identityProvider associated with a given {@code organization} or {@code null} if there is none. + */ + IdentityProviderModel getIdentityProvider(OrganizationModel organization); + + /** + * Removes the link between the given {@link OrganizationModel} and identity provider associated with it if such a link exists. + * + * @param organization the organization + * @return {@code true} if the link was removed, {@code false} otherwise + */ + boolean removeIdentityProvider(OrganizationModel organization); } diff --git a/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationIdentityProviderResource.java b/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationIdentityProviderResource.java new file mode 100644 index 0000000000..5f979fe534 --- /dev/null +++ b/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationIdentityProviderResource.java @@ -0,0 +1,176 @@ +/* + * 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 jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Response.Status; +import jakarta.ws.rs.ext.Provider; +import java.util.Objects; +import org.keycloak.models.IdentityProviderModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.ModelException; +import org.keycloak.models.OrganizationModel; +import org.keycloak.models.RealmModel; +import org.keycloak.models.utils.ModelToRepresentation; +import org.keycloak.organization.OrganizationProvider; +import org.keycloak.representations.idm.IdentityProviderRepresentation; +import org.keycloak.services.ErrorResponse; +import org.keycloak.services.resources.admin.AdminEventBuilder; +import org.keycloak.services.resources.admin.IdentityProviderResource; +import org.keycloak.services.resources.admin.IdentityProvidersResource; +import org.keycloak.services.resources.admin.permissions.AdminPermissionEvaluator; + +@Provider +public class OrganizationIdentityProviderResource { + + private final KeycloakSession session; + private final RealmModel realm; + private final OrganizationProvider organizationProvider; + private final OrganizationModel organization; + private final AdminPermissionEvaluator auth; + private final AdminEventBuilder adminEvent; + + public OrganizationIdentityProviderResource() { + // needed for registering to the JAX-RS stack + this(null, null, null, null); + } + + public OrganizationIdentityProviderResource(KeycloakSession session, OrganizationModel organization, AdminPermissionEvaluator auth, AdminEventBuilder adminEvent) { + this.session = session; + this.realm = session == null ? null : session.getContext().getRealm(); + this.organizationProvider = session == null ? null : session.getProvider(OrganizationProvider.class); + this.organization = organization; + this.auth = auth; + this.adminEvent = adminEvent; + } + + @POST + @Consumes(MediaType.APPLICATION_JSON) + public Response addIdentityProvider(IdentityProviderRepresentation providerRep) { + + IdentityProviderModel identityProvider = organizationProvider.getIdentityProvider(organization); + if (identityProvider != null) { + throw ErrorResponse.error("Organization already assigned with an identity provider.", Status.BAD_REQUEST); + } + + //create IdP within the realm + Response response = new IdentityProvidersResource(realm, session, auth, adminEvent).create(providerRep); + + if (Status.CREATED.getStatusCode() == response.getStatus()) { + + //get the created IdP from session + identityProvider = realm.getIdentityProviderByAlias(providerRep.getAlias()); + + String errorMessage; + try { + if (organizationProvider.addIdentityProvider(organization, identityProvider)) { + return response; + } + errorMessage = "Assigning the Identity provider with the organization was not succesful."; + } catch (ModelException me) { + errorMessage = me.getMessage(); + } + throw ErrorResponse.error(errorMessage, Status.BAD_REQUEST); + } + + return response; + } + + @GET + @Produces(MediaType.APPLICATION_JSON) + public IdentityProviderRepresentation getIdentityProvider() { + IdentityProviderModel identityProvider = organizationProvider.getIdentityProvider(organization); + return identityProvider == null ? null : toRepresentation(identityProvider); + } + + @DELETE + public Response delete() { + IdentityProviderModel identityProvider = getIdentityProviderModel(); + + Response response = getIdentityProviderResource(identityProvider).delete(); + + // remove link between IdP and the organization if the IdP deletetion was successful + if (Status.NO_CONTENT.getStatusCode() == response.getStatus()) { + String errorMessage; + try { + if (organizationProvider.removeIdentityProvider(organization)) { + return response; + } + errorMessage = "Removing the Identity provider from the organization was not succesful."; + } catch (ModelException me) { + errorMessage = me.getMessage(); + } + throw ErrorResponse.error(errorMessage, Status.BAD_REQUEST); + } + + return response; + } + + @PUT + @Consumes(MediaType.APPLICATION_JSON) + public Response update(IdentityProviderRepresentation providerRep) { + IdentityProviderModel identityProvider = getIdentityProviderModel(); + + Response response = getIdentityProviderResource(identityProvider).update(providerRep); + + //update link between IdP and the organization if the update of IdP was successful and the IdP alias differs + if (Status.NO_CONTENT.getStatusCode() == response.getStatus() && + ! Objects.equals(identityProvider.getAlias(), providerRep.getAlias())) { + + //get the updated IdP from session + identityProvider = realm.getIdentityProviderByAlias(providerRep.getAlias()); + + String errorMessage; + try { + if (organizationProvider.removeIdentityProvider(organization) && + organizationProvider.addIdentityProvider(organization, identityProvider)) { + return response; + } + errorMessage = "Updating the Identity provider was not succesful."; + } catch (ModelException me) { + errorMessage = me.getMessage(); + } + throw ErrorResponse.error(errorMessage, Status.BAD_REQUEST); + } + + return response; + } + + private IdentityProviderRepresentation toRepresentation(IdentityProviderModel idp) { + return ModelToRepresentation.toRepresentation(realm, idp); + } + + private IdentityProviderResource getIdentityProviderResource(IdentityProviderModel idp) { + return new IdentityProviderResource(auth, realm, session, idp, adminEvent); + } + + private IdentityProviderModel getIdentityProviderModel() { + IdentityProviderModel identityProvider = organizationProvider.getIdentityProvider(organization); + if (identityProvider == null) { + throw ErrorResponse.error("Organization doesn't have assigned an identity provider.", Status.NOT_FOUND); + } + return identityProvider; + } +} diff --git a/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationMemberResource.java b/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationMemberResource.java index 6c0814c185..c3011a3012 100644 --- a/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationMemberResource.java +++ b/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationMemberResource.java @@ -19,7 +19,6 @@ 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; @@ -39,11 +38,11 @@ import org.keycloak.models.ModelException; import org.keycloak.models.OrganizationModel; import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; -import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.models.utils.ModelToRepresentation; import org.keycloak.organization.OrganizationProvider; import org.keycloak.representations.idm.OrganizationRepresentation; import org.keycloak.representations.idm.UserRepresentation; +import org.keycloak.services.ErrorResponse; import org.keycloak.services.resources.admin.AdminEventBuilder; import org.keycloak.services.resources.admin.UserResource; import org.keycloak.services.resources.admin.UsersResource; @@ -82,26 +81,26 @@ public class OrganizationMemberResource { @Consumes(MediaType.APPLICATION_JSON) public Response addMember(UserRepresentation rep) { if (rep == null || !Objects.equals(rep.getUsername(), rep.getEmail())) { - throw new BadRequestException("To add a member to the organization it is expected the username and the email is the same."); + throw ErrorResponse.error("To add a member to the organization it is expected the username and the email is the same.", Status.BAD_REQUEST); } UsersResource usersResource = new UsersResource(session, auth, adminEvent); Response response = usersResource.createUser(rep); if (Status.CREATED.getStatusCode() == response.getStatus()) { - RealmModel realm = session.getContext().getRealm(); + UserModel member = session.users().getUserByUsername(realm, rep.getEmail()); - OrganizationProvider provider = session.getProvider(OrganizationProvider.class); + String errorMessage; try { if (provider.addMember(organization, member)) { - return Response.created(session.getContext().getUri().getAbsolutePathBuilder().path(member.getId()).build()).build(); + return response; } + errorMessage = "Assigning the User as member of the organization was not succesful."; } catch (ModelException me) { - throw new BadRequestException(me.getMessage()); + errorMessage = me.getMessage(); } - - throw new BadRequestException(); + throw ErrorResponse.error(errorMessage, Status.BAD_REQUEST); } return response; @@ -118,7 +117,7 @@ public class OrganizationMemberResource { @Produces(MediaType.APPLICATION_JSON) public UserRepresentation get(@PathParam("id") String id) { if (StringUtil.isBlank(id)) { - throw new BadRequestException(); + throw ErrorResponse.error("id cannot be null", Status.BAD_REQUEST); } return toRepresentation(getMember(id)); @@ -128,7 +127,7 @@ public class OrganizationMemberResource { @DELETE public Response delete(@PathParam("id") String id) { if (StringUtil.isBlank(id)) { - throw new BadRequestException(); + throw ErrorResponse.error("id cannot be null", Status.BAD_REQUEST); } UserModel member = getMember(id); @@ -148,7 +147,7 @@ public class OrganizationMemberResource { @Produces(MediaType.APPLICATION_JSON) public OrganizationRepresentation getOrganization(@PathParam("id") String id) { if (StringUtil.isBlank(id)) { - throw new BadRequestException(); + throw ErrorResponse.error("id cannot be null", Status.BAD_REQUEST); } UserModel member = getMember(id); 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 index 2eda49d9ca..98963cb244 100644 --- a/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationResource.java +++ b/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationResource.java @@ -119,6 +119,11 @@ public class OrganizationResource { return new OrganizationMemberResource(session, organization, auth, adminEvent); } + @Path("{id}/identity-provider") + public OrganizationIdentityProviderResource identityProvider(@PathParam("id") String id) { + return new OrganizationIdentityProviderResource(session, getOrganization(id), auth, adminEvent); + } + private OrganizationModel getOrganization(String id) { if (id == null) { throw new BadRequestException(); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationIdentityProviderTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationIdentityProviderTest.java new file mode 100644 index 0000000000..a978cf6955 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationIdentityProviderTest.java @@ -0,0 +1,138 @@ +/* + * 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.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.nullValue; + +import jakarta.ws.rs.core.Response; +import org.junit.Before; +import org.junit.Test; +import org.keycloak.admin.client.resource.OrganizationIdentityProviderResource; +import org.keycloak.admin.client.resource.OrganizationResource; +import org.keycloak.common.Profile.Feature; +import org.keycloak.representations.idm.IdentityProviderRepresentation; +import org.keycloak.representations.idm.OrganizationRepresentation; +import org.keycloak.testsuite.arquillian.annotation.EnableFeature; + +@EnableFeature(Feature.ORGANIZATION) +public class OrganizationIdentityProviderTest extends AbstractOrganizationTest { + + private final String idpAlias = "org-identity-provider"; + + @Before + public void addCleanups() { + addCleanupIdP(idpAlias); + } + + @Test + public void testCRUD() { + OrganizationIdentityProviderResource orgIdPResource = testRealm().organizations().get(createOrganization().getId()).identityProvider(); + + //create, read + IdentityProviderRepresentation idpRepresentation = createRep(idpAlias, "oidc"); + try (Response response = orgIdPResource.create(idpRepresentation)) { + assertThat(response.getStatus(), equalTo(Response.Status.CREATED.getStatusCode())); + } + idpRepresentation = orgIdPResource.toRepresentation(); + assertThat(idpRepresentation.getAlias(), equalTo(idpAlias)); + + String updatedIdpAlias = "updated-org-identity-provider"; + //update + idpRepresentation.setAlias(updatedIdpAlias); + try (Response response = orgIdPResource.update(idpRepresentation)) { + assertThat(response.getStatus(), equalTo(Response.Status.NO_CONTENT.getStatusCode())); + addCleanupIdP(updatedIdpAlias); + } + assertThat(orgIdPResource.toRepresentation().getAlias(), equalTo(updatedIdpAlias)); + + //delete + try (Response response = orgIdPResource.delete()) { + assertThat(response.getStatus(), equalTo(Response.Status.NO_CONTENT.getStatusCode())); + } + assertThat(orgIdPResource.toRepresentation(), nullValue()); + } + + @Test + public void tryCreateSecondIdp() { + OrganizationIdentityProviderResource orgIdPResource = testRealm().organizations().get(createOrganization().getId()).identityProvider(); + + IdentityProviderRepresentation idpRepresentation = createRep(idpAlias, "oidc"); + try (Response response = orgIdPResource.create(idpRepresentation)) { + assertThat(response.getStatus(), equalTo(Response.Status.CREATED.getStatusCode())); + } + + idpRepresentation.setAlias("another-idp"); + try (Response response = orgIdPResource.create(idpRepresentation)) { + assertThat(response.getStatus(), equalTo(Response.Status.BAD_REQUEST.getStatusCode())); + } + } + + @Test(expected = jakarta.ws.rs.NotFoundException.class) + public void removingOrgShouldRemoveIdP() { + OrganizationRepresentation orgRep = createOrganization(); + OrganizationResource orgResource = testRealm().organizations().get(orgRep.getId()); + + OrganizationIdentityProviderResource orgIdPResource = orgResource.identityProvider(); + + IdentityProviderRepresentation idpRepresentation = createRep(idpAlias, "oidc"); + try (Response response = orgIdPResource.create(idpRepresentation)) { + assertThat(response.getStatus(), equalTo(Response.Status.CREATED.getStatusCode())); + } + + try (Response response = orgResource.delete()) { + assertThat(response.getStatus(), equalTo(Response.Status.NO_CONTENT.getStatusCode())); + } + + testRealm().identityProviders().get(idpAlias).toRepresentation(); + } + + @Test + public void tryUpdateAndRemoveIdPNotAssignedToOrg() { + OrganizationRepresentation orgRep = createOrganization(); + OrganizationResource orgResource = testRealm().organizations().get(orgRep.getId()); + + OrganizationIdentityProviderResource orgIdPResource = orgResource.identityProvider(); + + IdentityProviderRepresentation idpRepresentation = createRep(idpAlias, "oidc"); + //create IdP in realm not bound to Org + testRealm().identityProviders().create(idpRepresentation).close(); + + try (Response response = orgIdPResource.update(idpRepresentation)) { + assertThat(response.getStatus(), equalTo(Response.Status.NOT_FOUND.getStatusCode())); + } + try (Response response = orgIdPResource.delete()) { + assertThat(response.getStatus(), equalTo(Response.Status.NOT_FOUND.getStatusCode())); + } + } + + private IdentityProviderRepresentation createRep(String alias, String providerId) { + IdentityProviderRepresentation idp = new IdentityProviderRepresentation(); + + idp.setAlias(alias); + idp.setDisplayName(alias); + idp.setProviderId(providerId); + idp.setEnabled(true); + return idp; + } + + private void addCleanupIdP(String alias) { + getCleanup().addCleanup(() -> testRealm().identityProviders().get(alias).remove()); + } +} diff --git a/testsuite/integration-arquillian/tests/base/testsuites/database-suite b/testsuite/integration-arquillian/tests/base/testsuites/database-suite index 2b9c10d629..827343d353 100644 --- a/testsuite/integration-arquillian/tests/base/testsuites/database-suite +++ b/testsuite/integration-arquillian/tests/base/testsuites/database-suite @@ -17,3 +17,4 @@ TransactionsTest UserProfileTest org.keycloak.testsuite.admin.** org.keycloak.testsuite.authz.**ManagementTest +org.keycloak.testsuite.organization.admin.**