From 51352622aad1ea31a91c06e3006db04cf83be2dc Mon Sep 17 00:00:00 2001 From: Pedro Igor Date: Tue, 23 Apr 2024 14:25:05 -0300 Subject: [PATCH] Allow adding realm users as an organization member Closes #29023 Signed-off-by: Pedro Igor --- .../resource/OrganizationMemberResource.java | 6 - .../resource/OrganizationMembersResource.java | 2 +- .../resource/OrganizationsResource.java | 10 + .../jpa/JpaOrganizationProvider.java | 59 +++++- .../organization/jpa/OrganizationAdapter.java | 20 ++ .../organization/OrganizationProvider.java | 31 +++ .../keycloak/models/OrganizationModel.java | 4 +- .../model/IdentityProviderBean.java | 4 + .../resource/OrganizationMemberResource.java | 42 ++-- .../browser/OrganizationAuthenticator.java | 24 ++- ...OrganizationAwareIdentityProviderBean.java | 58 ++++++ .../OrganizationMemberValidator.java | 10 +- ...DeclarativeUserProfileProviderFactory.java | 14 +- .../admin/AbstractOrganizationTest.java | 32 ++- .../OrganizationAdminPermissionsTest.java | 189 ++++++++++++++++++ ...rganizationBrokerSelfRegistrationTest.java | 107 +++++----- .../OrganizationMemberAuthenticationTest.java | 89 +++++++++ .../admin/OrganizationMemberTest.java | 119 ++++++----- .../organization/admin/OrganizationTest.java | 157 --------------- 19 files changed, 663 insertions(+), 314 deletions(-) create mode 100644 services/src/main/java/org/keycloak/organization/authentication/authenticators/browser/OrganizationAwareIdentityProviderBean.java create mode 100755 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationAdminPermissionsTest.java create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationMemberAuthenticationTest.java diff --git a/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/OrganizationMemberResource.java b/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/OrganizationMemberResource.java index a836933d44..f8d2d48dca 100644 --- a/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/OrganizationMemberResource.java +++ b/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/OrganizationMemberResource.java @@ -17,10 +17,8 @@ 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; @@ -32,10 +30,6 @@ public interface OrganizationMemberResource { @Produces(MediaType.APPLICATION_JSON) UserRepresentation toRepresentation(); - @PUT - @Consumes(MediaType.APPLICATION_JSON) - Response update(UserRepresentation organization); - @DELETE Response delete(); } diff --git a/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/OrganizationMembersResource.java b/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/OrganizationMembersResource.java index a3baaa1337..7cf4f1a583 100644 --- a/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/OrganizationMembersResource.java +++ b/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/OrganizationMembersResource.java @@ -34,7 +34,7 @@ public interface OrganizationMembersResource { @POST @Consumes(MediaType.APPLICATION_JSON) - Response addMember(UserRepresentation member); + Response addMember(String userId); @GET @Produces(MediaType.APPLICATION_JSON) 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 index 88d91c112f..a6b8c5bb07 100644 --- 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 @@ -67,4 +67,14 @@ public interface OrganizationsResource { @QueryParam("first") Integer first, @QueryParam("max") Integer max ); + + /** + * Return all organizations that match the specified filter. + * + * @param search a {@code String} representing either an organization name or domain. + * @return a list containing the matched organizations. + */ + @GET + @Produces(MediaType.APPLICATION_JSON) + List search(@QueryParam("search") String search); } 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 5609586409..a11bac9464 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 @@ -17,10 +17,11 @@ package org.keycloak.organization.jpa; -import static org.keycloak.models.OrganizationModel.USER_ORGANIZATION_ATTRIBUTE; +import static org.keycloak.models.OrganizationModel.ORGANIZATION_ATTRIBUTE; import static org.keycloak.models.jpa.PaginationUtils.paginateQuery; import static org.keycloak.utils.StreamsUtil.closing; +import java.util.List; import java.util.Set; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -29,6 +30,7 @@ import jakarta.persistence.EntityManager; import jakarta.persistence.NoResultException; import jakarta.persistence.TypedQuery; import org.keycloak.connections.jpa.JpaConnectionProvider; +import org.keycloak.models.FederatedIdentityModel; import org.keycloak.models.GroupModel; import org.keycloak.models.GroupProvider; import org.keycloak.models.IdentityProviderModel; @@ -94,7 +96,7 @@ public class JpaOrganizationProvider implements OrganizationProvider { 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)); + userProvider.getGroupMembersStream(realm, group).forEach(userModel -> removeMember(organization, userModel)); groupProvider.removeGroup(realm, group); realm.removeIdentityProviderByAlias(entity.getIdpAlias()); @@ -122,12 +124,12 @@ public class JpaOrganizationProvider implements OrganizationProvider { return false; } - if (user.getFirstAttribute(USER_ORGANIZATION_ATTRIBUTE) != null) { + if (user.getFirstAttribute(ORGANIZATION_ATTRIBUTE) != null) { throw new ModelException("User [" + user.getId() + "] is a member of a different organization"); } user.joinGroup(group); - user.setSingleAttribute(USER_ORGANIZATION_ATTRIBUTE, entity.getId()); + user.setSingleAttribute(ORGANIZATION_ATTRIBUTE, entity.getId()); return true; } @@ -185,7 +187,7 @@ public class JpaOrganizationProvider implements OrganizationProvider { return null; } - String orgId = user.getFirstAttribute(USER_ORGANIZATION_ATTRIBUTE); + String orgId = user.getFirstAttribute(ORGANIZATION_ATTRIBUTE); if (organization.getId().equals(orgId)) { return user; @@ -198,7 +200,7 @@ public class JpaOrganizationProvider implements OrganizationProvider { public OrganizationModel getByMember(UserModel member) { throwExceptionIfObjectIsNull(member, "User"); - String orgId = member.getFirstAttribute(USER_ORGANIZATION_ATTRIBUTE); + String orgId = member.getFirstAttribute(ORGANIZATION_ATTRIBUTE); if (orgId == null) { return null; @@ -214,6 +216,9 @@ public class JpaOrganizationProvider implements OrganizationProvider { OrganizationEntity organizationEntity = getEntity(organization.getId()); organizationEntity.setIdpAlias(identityProvider.getAlias()); + identityProvider.getConfig().put(ORGANIZATION_ATTRIBUTE, organization.getId()); + realm.updateIdentityProvider(identityProvider); + return true; } @@ -236,6 +241,48 @@ public class JpaOrganizationProvider implements OrganizationProvider { return true; } + @Override + public boolean isManagedMember(OrganizationModel organization, UserModel member) { + throwExceptionIfObjectIsNull(organization, "organization"); + + if (member == null) { + return false; + } + + IdentityProviderModel identityProvider = organization.getIdentityProvider(); + + if (identityProvider == null) { + return false; + } + + FederatedIdentityModel federatedIdentity = userProvider.getFederatedIdentity(realm, member, identityProvider.getAlias()); + + return federatedIdentity != null; + } + + @Override + public boolean removeMember(OrganizationModel organization, UserModel member) { + throwExceptionIfObjectIsNull(organization, "organization"); + throwExceptionIfObjectIsNull(member, "member"); + + OrganizationModel userOrg = getByMember(member); + + if (userOrg == null || !userOrg.equals(organization)) { + return false; + } + + if (isManagedMember(organization, member)) { + userProvider.removeUser(realm, member); + } else { + List organizations = member.getAttributes().get(ORGANIZATION_ATTRIBUTE); + organizations.remove(organization.getId()); + member.setAttribute(ORGANIZATION_ATTRIBUTE, organizations); + member.leaveGroup(getOrganizationGroup(organization)); + } + + return true; + } + @Override public boolean isEnabled() { return getAllStream().findAny().isPresent(); 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 e4d118aeff..aedc715275 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 @@ -32,6 +32,7 @@ import org.keycloak.models.ModelValidationException; import org.keycloak.models.OrganizationDomainModel; import org.keycloak.models.OrganizationModel; import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; import org.keycloak.models.jpa.JpaModel; import org.keycloak.models.jpa.entities.OrganizationDomainEntity; import org.keycloak.models.jpa.entities.OrganizationEntity; @@ -133,6 +134,11 @@ public final class OrganizationAdapter implements OrganizationModel, JpaModelIndicates if the given {@code member} is managed by the organization. + * + *

A member is managed by the organization whenever the member cannot exist without the organization they belong + * to so that their lifecycle is bound to the organization lifecycle. For instance, when a member is federated from + * the identity provider associated with an organization, there is a strong relationship between the member identity + * and the organization. + * + *

On the other hand, existing realm users whose identities are not intrinsically linked to an organization but + * are eventually joining an organization are not managed by the organization. They have a lifecycle that does not + * depend on the organization they are linked to. + * + * @param organization the organization + * @param member the member + * @return {@code true} if the {@code member} is managed by the given {@code organization}. Otherwise, returns {@code false} + */ + boolean isManagedMember(OrganizationModel organization, UserModel member); + + /** + *

Removes a member from the organization. + * + *

This method can either remove the given {@code member} entirely from the realm (and the organization) or only + * remove the link to the {@code organization} so that the user still exists but is no longer a member of the organization. + * The decision to remove the user entirely or only the link depends on whether the user is managed by the organization or not, respectively. + * + * @param organization the organization + * @param member the member + * @return {@code true} if the given {@code member} is a member and was successfully removed from the organization. Otherwise, returns {@code false} + */ + boolean removeMember(OrganizationModel organization, UserModel member); } diff --git a/server-spi/src/main/java/org/keycloak/models/OrganizationModel.java b/server-spi/src/main/java/org/keycloak/models/OrganizationModel.java index 1427004cd7..facdf90011 100644 --- a/server-spi/src/main/java/org/keycloak/models/OrganizationModel.java +++ b/server-spi/src/main/java/org/keycloak/models/OrganizationModel.java @@ -24,7 +24,7 @@ import java.util.stream.Stream; public interface OrganizationModel { - String USER_ORGANIZATION_ATTRIBUTE = "kc.org"; + String ORGANIZATION_ATTRIBUTE = "kc.org"; String getId(); @@ -41,4 +41,6 @@ public interface OrganizationModel { void setDomains(Set domains); IdentityProviderModel getIdentityProvider(); + + boolean isManaged(UserModel user); } diff --git a/services/src/main/java/org/keycloak/forms/login/freemarker/model/IdentityProviderBean.java b/services/src/main/java/org/keycloak/forms/login/freemarker/model/IdentityProviderBean.java index 2e1b04a349..d049e18bc6 100755 --- a/services/src/main/java/org/keycloak/forms/login/freemarker/model/IdentityProviderBean.java +++ b/services/src/main/java/org/keycloak/forms/login/freemarker/model/IdentityProviderBean.java @@ -45,6 +45,10 @@ public class IdentityProviderBean { private RealmModel realm; private final KeycloakSession session; + public IdentityProviderBean() { + this.session = null; + } + public IdentityProviderBean(RealmModel realm, KeycloakSession session, List identityProviders, URI baseURI) { this.realm = realm; this.session = session; 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 dcfd659149..ec307ad1f8 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 @@ -79,32 +79,23 @@ public class OrganizationMemberResource { @POST @Consumes(MediaType.APPLICATION_JSON) - public Response addMember(UserRepresentation rep) { + public Response addMember(String id) { auth.realm().requireManageRealm(); - if (rep == null || !Objects.equals(rep.getUsername(), rep.getEmail())) { - throw ErrorResponse.error("To add a member to the organization it is expected the username and the email is the same.", Status.BAD_REQUEST); + UserModel user = session.users().getUserById(realm, id); + + if (user == null) { + throw ErrorResponse.error("User does not exist", Status.BAD_REQUEST); } - UsersResource usersResource = new UsersResource(session, auth, adminEvent); - Response response = usersResource.createUser(rep); - - if (Status.CREATED.getStatusCode() == response.getStatus()) { - - UserModel member = session.users().getUserByUsername(realm, rep.getEmail()); - - String errorMessage; - try { - if (provider.addMember(organization, member)) { - return response; - } - errorMessage = "Assigning the User as member of the organization was not succesful."; - } catch (ModelException me) { - errorMessage = me.getMessage(); + try { + if (provider.addMember(organization, user)) { + return Response.created(session.getContext().getUri().getAbsolutePathBuilder().path(user.getId()).build()).build(); } - throw ErrorResponse.error(errorMessage, Status.BAD_REQUEST); + } catch (ModelException me) { + throw ErrorResponse.error(me.getMessage(), Status.BAD_REQUEST); } - return response; + throw ErrorResponse.error("User is already a member of the organization.", Status.CONFLICT); } @GET @@ -136,7 +127,11 @@ public class OrganizationMemberResource { UserModel member = getMember(id); - return new UserResource(session, member, auth, adminEvent).deleteUser(); + if (provider.removeMember(organization, member)) { + return Response.noContent().build(); + } + + throw ErrorResponse.error("Not a member of the organization", Status.BAD_REQUEST); } @Path("{id}") @@ -158,6 +153,11 @@ public class OrganizationMemberResource { UserModel member = getMember(id); OrganizationModel organization = provider.getByMember(member); + + if (organization == null) { + throw ErrorResponse.error("Not associated with an organization", Status.NOT_FOUND); + } + OrganizationRepresentation rep = new OrganizationRepresentation(); rep.setId(organization.getId()); diff --git a/services/src/main/java/org/keycloak/organization/authentication/authenticators/browser/OrganizationAuthenticator.java b/services/src/main/java/org/keycloak/organization/authentication/authenticators/browser/OrganizationAuthenticator.java index e0576eaa2f..c7d11b5fce 100644 --- a/services/src/main/java/org/keycloak/organization/authentication/authenticators/browser/OrganizationAuthenticator.java +++ b/services/src/main/java/org/keycloak/organization/authentication/authenticators/browser/OrganizationAuthenticator.java @@ -17,9 +17,12 @@ package org.keycloak.organization.authentication.authenticators.browser; +import java.util.function.BiFunction; + import jakarta.ws.rs.core.MultivaluedMap; import org.keycloak.authentication.AuthenticationFlowContext; import org.keycloak.authentication.authenticators.browser.IdentityProviderAuthenticator; +import org.keycloak.forms.login.freemarker.model.IdentityProviderBean; import org.keycloak.http.HttpRequest; import org.keycloak.models.IdentityProviderModel; import org.keycloak.models.KeycloakSession; @@ -40,7 +43,7 @@ public class OrganizationAuthenticator extends IdentityProviderAuthenticator { OrganizationProvider provider = getOrganizationProvider(); if (!provider.isEnabled()) { - context.attempted(); + attempted(context); return; } @@ -61,7 +64,7 @@ public class OrganizationAuthenticator extends IdentityProviderAuthenticator { String domain = getEmailDomain(username); if (domain == null) { - context.attempted(); + attempted(context); return; } @@ -69,20 +72,33 @@ public class OrganizationAuthenticator extends IdentityProviderAuthenticator { OrganizationModel organization = provider.getByDomainName(domain); if (organization == null) { - context.attempted(); + attempted(context); return; } IdentityProviderModel identityProvider = organization.getIdentityProvider(); if (identityProvider == null) { - context.attempted(); + attempted(context); return; } redirect(context, identityProvider.getAlias(), username); } + private void attempted(AuthenticationFlowContext context) { + context.form() + .setAttributeMapper(attributes -> { + attributes.computeIfPresent("social", createOrganizationAwareSocialBean()); + return attributes; + }); + context.attempted(); + } + + private BiFunction createOrganizationAwareSocialBean() { + return (key, bean) -> new OrganizationAwareIdentityProviderBean((IdentityProviderBean) bean, session); + } + private OrganizationProvider getOrganizationProvider() { return session.getProvider(OrganizationProvider.class); } diff --git a/services/src/main/java/org/keycloak/organization/authentication/authenticators/browser/OrganizationAwareIdentityProviderBean.java b/services/src/main/java/org/keycloak/organization/authentication/authenticators/browser/OrganizationAwareIdentityProviderBean.java new file mode 100644 index 0000000000..afc06a805b --- /dev/null +++ b/services/src/main/java/org/keycloak/organization/authentication/authenticators/browser/OrganizationAwareIdentityProviderBean.java @@ -0,0 +1,58 @@ +/* + * 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.authentication.authenticators.browser; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.keycloak.forms.login.freemarker.model.IdentityProviderBean; +import org.keycloak.models.IdentityProviderModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.OrganizationModel; +import org.keycloak.models.RealmModel; + +public class OrganizationAwareIdentityProviderBean extends IdentityProviderBean { + + private final IdentityProviderBean delegate; + private final KeycloakSession session; + + public OrganizationAwareIdentityProviderBean(IdentityProviderBean delegate, KeycloakSession session) { + this.delegate = delegate; + this.session = session; + } + + @Override + public List getProviders() { + return Optional.ofNullable(delegate.getProviders()).orElse(List.of()).stream() + .filter(this::filterOrganizationalIdentityProvider) + .toList(); + } + + @Override + public boolean isDisplayInfo() { + return delegate.isDisplayInfo(); + } + + private boolean filterOrganizationalIdentityProvider(IdentityProvider idp) { + RealmModel realm = session.getContext().getRealm(); + IdentityProviderModel model = realm.getIdentityProviderByAlias(idp.getAlias()); + Map config = model.getConfig(); + return !config.containsKey(OrganizationModel.ORGANIZATION_ATTRIBUTE); + } +} diff --git a/services/src/main/java/org/keycloak/organization/validator/OrganizationMemberValidator.java b/services/src/main/java/org/keycloak/organization/validator/OrganizationMemberValidator.java index cebd252066..05980d0948 100644 --- a/services/src/main/java/org/keycloak/organization/validator/OrganizationMemberValidator.java +++ b/services/src/main/java/org/keycloak/organization/validator/OrganizationMemberValidator.java @@ -70,7 +70,7 @@ public class OrganizationMemberValidator extends AbstractSimpleValidator impleme } private void validateEmailDomain(String email, String inputHint, ValidationContext context, OrganizationModel organization) { - if (UserModel.USERNAME.equals(inputHint) || UserModel.EMAIL.equals(inputHint)) { + if (UserModel.EMAIL.equals(inputHint)) { if (StringUtil.isBlank(email)) { context.addError(new ValidationError(ID, inputHint, "Email not set")); return; @@ -80,6 +80,14 @@ public class OrganizationMemberValidator extends AbstractSimpleValidator impleme return; } + UserProfileAttributeValidationContext upContext = (UserProfileAttributeValidationContext) context; + AttributeContext attributeContext = upContext.getAttributeContext(); + UserModel user = attributeContext.getUser(); + + if (!organization.isManaged(user)) { + return; + } + String domain = email.substring(email.indexOf('@') + 1); Stream expectedDomains = organization.getDomains(); diff --git a/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProviderFactory.java b/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProviderFactory.java index 77ba37ce77..9dc99e0b5c 100644 --- a/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProviderFactory.java +++ b/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProviderFactory.java @@ -42,6 +42,7 @@ import org.keycloak.component.ComponentValidationException; import org.keycloak.models.KeycloakContext; import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.models.OrganizationModel; import org.keycloak.models.RealmModel; import org.keycloak.models.RequiredActionProviderModel; import org.keycloak.models.UserModel; @@ -56,6 +57,7 @@ import org.keycloak.userprofile.validator.BrokeringFederatedUsernameHasValueVali import org.keycloak.userprofile.validator.DuplicateEmailValidator; import org.keycloak.userprofile.validator.DuplicateUsernameValidator; import org.keycloak.userprofile.validator.EmailExistsAsUsernameValidator; +import org.keycloak.userprofile.validator.ImmutableAttributeValidator; import org.keycloak.userprofile.validator.ReadOnlyAttributeUnchangedValidator; import org.keycloak.userprofile.validator.RegistrationEmailAsUsernameEmailValueValidator; import org.keycloak.userprofile.validator.RegistrationEmailAsUsernameUsernameValueValidator; @@ -348,10 +350,20 @@ public class DeclarativeUserProfileProviderFactory implements UserProfileProvide for (AttributeMetadata attribute : metadata.getAttributes()) { String name = attribute.getName(); - if (UserModel.EMAIL.equals(name) || UserModel.USERNAME.equals(name)) { + if (UserModel.EMAIL.equals(name)) { attribute.addValidators(List.of(new AttributeValidatorMetadata(OrganizationMemberValidator.ID))); } } + + metadata.addAttribute(OrganizationModel.ORGANIZATION_ATTRIBUTE, -1, + new AttributeValidatorMetadata(OrganizationMemberValidator.ID), + new AttributeValidatorMetadata(ImmutableAttributeValidator.ID)) + .addWriteCondition(context -> { + // the attribute can only be managed within the scope of the Organization API + // we assume, for now, that if the organization is set as a session attribute, we are operating within the scope if the Organization API + KeycloakSession session = context.getSession(); + return session.getAttribute(OrganizationModel.class.getName()) != null; + }); } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/AbstractOrganizationTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/AbstractOrganizationTest.java index 8c501c628f..f4a52c1e5e 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/AbstractOrganizationTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/AbstractOrganizationTest.java @@ -25,6 +25,7 @@ import java.util.function.Function; import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response.Status; +import org.jboss.arquillian.graphene.page.Page; import org.keycloak.admin.client.resource.OrganizationResource; import org.keycloak.representations.idm.OrganizationDomainRepresentation; import org.keycloak.representations.idm.OrganizationRepresentation; @@ -34,6 +35,10 @@ import org.keycloak.testsuite.admin.AbstractAdminTest; import org.keycloak.testsuite.admin.ApiUtil; import org.keycloak.testsuite.admin.Users; import org.keycloak.testsuite.broker.KcOidcBrokerConfiguration; +import org.keycloak.testsuite.pages.AppPage; +import org.keycloak.testsuite.pages.IdpConfirmLinkPage; +import org.keycloak.testsuite.pages.LoginPage; +import org.keycloak.testsuite.pages.UpdateAccountInformationPage; import org.keycloak.testsuite.util.UserBuilder; /** @@ -77,6 +82,18 @@ public abstract class AbstractOrganizationTest extends AbstractAdminTest { } }; + @Page + protected LoginPage loginPage; + + @Page + protected IdpConfirmLinkPage idpConfirmLinkPage; + + @Page + protected UpdateAccountInformationPage updateAccountInformationPage; + + @Page + protected AppPage appPage; + protected KcOidcBrokerConfiguration bc = brokerConfigFunction.apply(organizationName); @Override @@ -141,13 +158,20 @@ public abstract class AbstractOrganizationTest extends AbstractAdminTest { expected.setEnabled(true); Users.setPasswordFor(expected, memberPassword); - try (Response response = organization.members().addMember(expected)) { + try (Response response = testRealm().users().create(expected)) { + expected.setId(ApiUtil.getCreatedId(response)); + } + + getCleanup().addCleanup(() -> testRealm().users().get(expected.getId()).remove()); + + String userId = expected.getId(); + + try (Response response = organization.members().addMember(userId)) { assertEquals(Status.CREATED.getStatusCode(), response.getStatus()); - String id = ApiUtil.getCreatedId(response); - UserRepresentation actual = organization.members().member(id).toRepresentation(); + UserRepresentation actual = organization.members().member(userId).toRepresentation(); assertNotNull(expected); - assertEquals(id, actual.getId()); + assertEquals(userId, actual.getId()); assertEquals(expected.getUsername(), actual.getUsername()); assertEquals(expected.getEmail(), actual.getEmail()); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationAdminPermissionsTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationAdminPermissionsTest.java new file mode 100755 index 0000000000..0acdd2aed7 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationAdminPermissionsTest.java @@ -0,0 +1,189 @@ +/* + * 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.junit.Assert.fail; + +import jakarta.ws.rs.ForbiddenException; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Response.Status; +import org.hamcrest.Matchers; +import org.junit.Test; +import org.keycloak.admin.client.Keycloak; +import org.keycloak.admin.client.resource.RealmResource; +import org.keycloak.common.Profile.Feature; +import org.keycloak.models.AdminRoles; +import org.keycloak.models.Constants; +import org.keycloak.representations.idm.IdentityProviderRepresentation; +import org.keycloak.representations.idm.OrganizationRepresentation; +import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.representations.idm.UserRepresentation; +import org.keycloak.testsuite.admin.ApiUtil; +import org.keycloak.testsuite.arquillian.annotation.EnableFeature; +import org.keycloak.testsuite.util.AdminClientUtil; +import org.keycloak.testsuite.util.UserBuilder; + +@EnableFeature(Feature.ORGANIZATION) +public class OrganizationAdminPermissionsTest extends AbstractOrganizationTest { + + @Override + public void configureTestRealm(RealmRepresentation testRealm) { + testRealm.getUsers().add(UserBuilder.create().username("realmAdmin").password("password") + .role(Constants.REALM_MANAGEMENT_CLIENT_ID, AdminRoles.MANAGE_REALM) + .role(Constants.REALM_MANAGEMENT_CLIENT_ID, AdminRoles.MANAGE_IDENTITY_PROVIDERS) + .role(Constants.REALM_MANAGEMENT_CLIENT_ID, AdminRoles.MANAGE_USERS) + .build()); + } + + @Test + public void testManageRealmRole() throws Exception { + try ( + Keycloak manageRealmAdminClient = AdminClientUtil.createAdminClient(suiteContext.isAdapterCompatTesting(), + TEST_REALM_NAME, "realmAdmin", "password", Constants.ADMIN_CLI_CLIENT_ID, null); + Keycloak userAdminClient = AdminClientUtil.createAdminClient(suiteContext.isAdapterCompatTesting(), + TEST_REALM_NAME, "test-user@localhost", "password", Constants.ADMIN_CLI_CLIENT_ID, null) + ) { + RealmResource realmAdminResource = manageRealmAdminClient.realm(TEST_REALM_NAME); + RealmResource realmUserResource = userAdminClient.realm(TEST_REALM_NAME); + + /* Org */ + //create org + OrganizationRepresentation orgRep = createRepresentation("testOrg", "testOrg.org"); + String orgId; + try ( + Response userResponse = realmUserResource.organizations().create(orgRep); + Response adminResponse = realmAdminResource.organizations().create(orgRep) + ) { + assertThat(userResponse.getStatus(), equalTo(Status.FORBIDDEN.getStatusCode())); + assertThat(adminResponse.getStatus(), equalTo(Status.CREATED.getStatusCode())); + orgId = ApiUtil.getCreatedId(adminResponse); + getCleanup().addCleanup(() -> testRealm().organizations().get(orgId).delete().close()); + } + + //search for org + try { + realmUserResource.organizations().search("testOrg.org"); + fail("Expected ForbiddenException"); + } catch (ForbiddenException expected) {} + assertThat(realmAdminResource.organizations().search("testOrg.org"), Matchers.notNullValue()); + + //get org + try { + realmUserResource.organizations().get(orgId).toRepresentation(); + fail("Expected ForbiddenException"); + } catch (ForbiddenException expected) {} + assertThat(realmAdminResource.organizations().get(orgId).toRepresentation(), Matchers.notNullValue()); + + //update org + try (Response userResponse = realmUserResource.organizations().get(orgId).update(orgRep)) { + assertThat(userResponse.getStatus(), equalTo(Status.FORBIDDEN.getStatusCode())); + } + + //delete org + try (Response userResponse = realmUserResource.organizations().get(orgId).delete()) { + assertThat(userResponse.getStatus(), equalTo(Status.FORBIDDEN.getStatusCode())); + } + + /* IdP */ + IdentityProviderRepresentation idpRep = new IdentityProviderRepresentation(); + idpRep.setAlias("dummy"); + idpRep.setProviderId("oidc"); + //create IdP + try ( + Response userResponse = realmUserResource.organizations().get(orgId).identityProvider().create(idpRep); + Response adminResponse = realmAdminResource.organizations().get(orgId).identityProvider().create(idpRep) + ) { + assertThat(userResponse.getStatus(), equalTo(Status.FORBIDDEN.getStatusCode())); + assertThat(adminResponse.getStatus(), equalTo(Status.CREATED.getStatusCode())); + getCleanup().addCleanup(() -> testRealm().organizations().get(orgId).identityProvider().delete().close()); + } + + //get IdP + try { + //we should get 403, not 400 or 404 etc. + realmUserResource.organizations().get("non-existing").identityProvider().toRepresentation(); + fail("Expected ForbiddenException"); + } catch (ForbiddenException expected) {} + try { + realmUserResource.organizations().get(orgId).identityProvider().toRepresentation(); + fail("Expected ForbiddenException"); + } catch (ForbiddenException expected) {} + assertThat(realmAdminResource.organizations().get(orgId).identityProvider().toRepresentation(), Matchers.notNullValue()); + + //update IdP + try (Response userResponse = realmUserResource.organizations().get(orgId).identityProvider().update(idpRep)) { + assertThat(userResponse.getStatus(), equalTo(Status.FORBIDDEN.getStatusCode())); + } + + //delete IdP + try (Response userResponse = realmUserResource.organizations().get(orgId).identityProvider().delete()) { + assertThat(userResponse.getStatus(), equalTo(Status.FORBIDDEN.getStatusCode())); + } + + /* Members */ + UserRepresentation userRep = UserBuilder.create() + .username("user@testOrg.org") + .email("user@testOrg.org") + .build(); + + try (Response response = realmAdminResource.users().create(userRep)) { + userRep.setId(ApiUtil.getCreatedId(response)); + } + + String userId; + + //create member + try ( + Response userResponse = realmUserResource.organizations().get(orgId).members().addMember(userRep.getId()); + Response adminResponse = realmAdminResource.organizations().get(orgId).members().addMember(userRep.getId()) + ) { + assertThat(userResponse.getStatus(), equalTo(Status.FORBIDDEN.getStatusCode())); + assertThat(adminResponse.getStatus(), equalTo(Status.CREATED.getStatusCode())); + userId = ApiUtil.getCreatedId(adminResponse); + assertThat(userId, Matchers.notNullValue()); + getCleanup().addCleanup(() -> testRealm().organizations().get(orgId).members().member(userId).delete().close()); + } + + //get members + try { + //we should get 403, not 400 or 404 etc. + realmUserResource.organizations().get("non-existing").members().getAll(); + fail("Expected ForbiddenException"); + } catch (ForbiddenException expected) {} + try { + realmUserResource.organizations().get(orgId).members().getAll(); + fail("Expected ForbiddenException"); + } catch (ForbiddenException expected) {} + assertThat(realmAdminResource.organizations().get(orgId).members().getAll(), Matchers.notNullValue()); + + //get member + try { + realmUserResource.organizations().get(orgId).members().member(userId).toRepresentation(); + fail("Expected ForbiddenException"); + } catch (ForbiddenException expected) {} + assertThat(realmAdminResource.organizations().get(orgId).members().member(userId).toRepresentation(), Matchers.notNullValue()); + + //delete member + try (Response userResponse = realmUserResource.organizations().get(orgId).members().member(userId).delete()) { + assertThat(userResponse.getStatus(), equalTo(Status.FORBIDDEN.getStatusCode())); + } + } + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationBrokerSelfRegistrationTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationBrokerSelfRegistrationTest.java index c724a0d843..a5b6d662b7 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationBrokerSelfRegistrationTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationBrokerSelfRegistrationTest.java @@ -17,41 +17,32 @@ package org.keycloak.testsuite.organization.admin; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; import static org.keycloak.testsuite.broker.BrokerTestTools.waitForPage; import java.util.List; -import org.jboss.arquillian.graphene.page.Page; +import jakarta.ws.rs.BadRequestException; +import jakarta.ws.rs.NotFoundException; import org.junit.Test; +import org.keycloak.admin.client.resource.OrganizationMemberResource; import org.keycloak.admin.client.resource.OrganizationResource; import org.keycloak.admin.client.resource.UsersResource; import org.keycloak.common.Profile.Feature; import org.keycloak.models.IdentityProviderModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.utils.KeycloakModelUtils; +import org.keycloak.representations.idm.ErrorRepresentation; import org.keycloak.representations.idm.IdentityProviderRepresentation; import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.testsuite.Assert; import org.keycloak.testsuite.arquillian.annotation.EnableFeature; -import org.keycloak.testsuite.pages.AppPage; -import org.keycloak.testsuite.pages.IdpConfirmLinkPage; -import org.keycloak.testsuite.pages.LoginPage; -import org.keycloak.testsuite.pages.UpdateAccountInformationPage; import org.keycloak.testsuite.util.UserBuilder; @EnableFeature(Feature.ORGANIZATION) public class OrganizationBrokerSelfRegistrationTest extends AbstractOrganizationTest { - @Page - protected LoginPage loginPage; - - @Page - protected IdpConfirmLinkPage idpConfirmLinkPage; - - @Page - protected UpdateAccountInformationPage updateAccountInformationPage; - - @Page - protected AppPage appPage; - @Test public void testBrokerRegistration() { OrganizationResource organization = testRealm().organizations().get(createOrganization().getId()); @@ -80,39 +71,6 @@ public class OrganizationBrokerSelfRegistrationTest extends AbstractOrganization Assert.assertEquals(bc.getUserEmail(), loginPage.getUsername()); } - - @Test - public void testDefaultAuthenticationMechanismIfNotOrganizationMember() { - testRealm().organizations().get(createOrganization().getId()); - oauth.clientId("broker-app"); - - // login with email only - loginPage.open(bc.consumerRealmName()); - log.debug("Logging in"); - Assert.assertFalse(loginPage.isPasswordInputPresent()); - loginPage.loginUsername("user@noorg.org"); - - // check if the login page is shown - Assert.assertTrue(loginPage.isUsernameInputPresent()); - Assert.assertTrue(loginPage.isPasswordInputPresent()); - } - - @Test - public void testTryLoginWithUsernameNotAnEmail() { - testRealm().organizations().get(createOrganization().getId()); - oauth.clientId("broker-app"); - - // login with email only - loginPage.open(bc.consumerRealmName()); - log.debug("Logging in"); - Assert.assertFalse(loginPage.isPasswordInputPresent()); - loginPage.loginUsername("user"); - - // check if the login page is shown - Assert.assertTrue(loginPage.isUsernameInputPresent()); - Assert.assertTrue(loginPage.isPasswordInputPresent()); - } - @Test public void testLinkExistingAccount() { // create a realm user in the consumer realm @@ -182,6 +140,55 @@ public class OrganizationBrokerSelfRegistrationTest extends AbstractOrganization assertIsMember(bc.getUserEmail(), organization); } + @Test + public void testFailUpdateEmailWithDomainDifferentThanOrganization() { + OrganizationResource organization = testRealm().organizations().get(createOrganization().getId()); + + // add the member for the first time + assertBrokerRegistration(organization); + UserRepresentation member = getUserRepresentation(bc.getUserEmail()); + + member.setEmail(KeycloakModelUtils.generateId() + "@user.org"); + + try { + // member has a hard link with the organization, and the email must match the domains set to the organization + testRealm().users().get(member.getId()).update(member); + fail("Should fail because email domain does not match any from organization"); + } catch (BadRequestException expected) { + ErrorRepresentation error = expected.getResponse().readEntity(ErrorRepresentation.class); + assertEquals(UserModel.EMAIL, error.getField()); + assertEquals("Email domain does not match any domain from the organization", error.getErrorMessage()); + } + + member.setEmail(member.getEmail().replace("@user.org", "@" + organizationName + ".org")); + testRealm().users().get(member.getId()).update(member); + } + + @Test + public void testDelete() { + OrganizationResource organization = testRealm().organizations().get(createOrganization().getId()); + + // add the member for the first time + assertBrokerRegistration(organization); + UserRepresentation member = getUserRepresentation(bc.getUserEmail()); + member.setEmail(KeycloakModelUtils.generateId() + "@user.org"); + OrganizationMemberResource organizationMember = organization.members().member(member.getId()); + + organizationMember.delete().close(); + + try { + testRealm().users().get(member.getId()).toRepresentation(); + fail("it is managed member should be removed from the realm"); + } catch (NotFoundException expected) { + } + + try { + organizationMember.toRepresentation(); + fail("it is managed member should be removed from the realm"); + } catch (NotFoundException expected) { + } + } + private void assertBrokerRegistration(OrganizationResource organization) { // login with email only oauth.clientId("broker-app"); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationMemberAuthenticationTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationMemberAuthenticationTest.java new file mode 100644 index 0000000000..016f5f26a5 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationMemberAuthenticationTest.java @@ -0,0 +1,89 @@ +/* + * 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.keycloak.testsuite.broker.BrokerTestTools.waitForPage; + +import org.junit.Test; +import org.keycloak.admin.client.resource.OrganizationResource; +import org.keycloak.common.Profile.Feature; +import org.keycloak.representations.idm.UserRepresentation; +import org.keycloak.testsuite.Assert; +import org.keycloak.testsuite.arquillian.annotation.EnableFeature; + +@EnableFeature(Feature.ORGANIZATION) +public class OrganizationMemberAuthenticationTest extends AbstractOrganizationTest { + + @Test + public void testAuthenticateUnmanagedMember() { + OrganizationResource organization = testRealm().organizations().get(createOrganization().getId()); + UserRepresentation member = addMember(organization, "contractor@contractor.org"); + + // first try to log in using only the email + oauth.clientId("broker-app"); + loginPage.open(bc.consumerRealmName()); + Assert.assertFalse(loginPage.isPasswordInputPresent()); + Assert.assertFalse(loginPage.isSocialButtonPresent(bc.getIDPAlias())); + loginPage.loginUsername(member.getEmail()); + + // the email does not match an organization so redirect to the realm's default authentication mechanism + waitForPage(driver, "sign in to", true); + Assert.assertTrue("Driver should be on the provider realm page right now", + driver.getCurrentUrl().contains("/auth/realms/" + bc.consumerRealmName() + "/")); + Assert.assertTrue(loginPage.isPasswordInputPresent()); + Assert.assertEquals(member.getEmail(), loginPage.getUsername()); + // no idp should be shown because there is only a single idp that is bound to an organization + Assert.assertFalse(loginPage.isSocialButtonPresent(bc.getIDPAlias())); + + // the member should be able to log in using the credentials + loginPage.login(member.getEmail(), memberPassword); + appPage.assertCurrent(); + } + + @Test + public void testTryLoginWithUsernameNotAnEmail() { + testRealm().organizations().get(createOrganization().getId()); + oauth.clientId("broker-app"); + + // login with email only + loginPage.open(bc.consumerRealmName()); + log.debug("Logging in"); + Assert.assertFalse(loginPage.isPasswordInputPresent()); + loginPage.loginUsername("user"); + + // check if the login page is shown + Assert.assertTrue(loginPage.isUsernameInputPresent()); + Assert.assertTrue(loginPage.isPasswordInputPresent()); + } + + @Test + public void testDefaultAuthenticationMechanismIfNotOrganizationMember() { + testRealm().organizations().get(createOrganization().getId()); + oauth.clientId("broker-app"); + + // login with email only + loginPage.open(bc.consumerRealmName()); + log.debug("Logging in"); + Assert.assertFalse(loginPage.isPasswordInputPresent()); + loginPage.loginUsername("user@noorg.org"); + + // check if the login page is shown + Assert.assertTrue(loginPage.isUsernameInputPresent()); + Assert.assertTrue(loginPage.isPasswordInputPresent()); + } +} \ No newline at end of file diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationMemberTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationMemberTest.java index f0152292dd..3567e2e9b6 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationMemberTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationMemberTest.java @@ -17,12 +17,14 @@ package org.keycloak.testsuite.organization.admin; +import static org.hamcrest.MatcherAssert.assertThat; 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 static org.keycloak.models.OrganizationModel.USER_ORGANIZATION_ATTRIBUTE; +import static org.keycloak.models.OrganizationModel.ORGANIZATION_ATTRIBUTE; import java.util.ArrayList; import java.util.List; @@ -31,16 +33,19 @@ import jakarta.ws.rs.BadRequestException; import jakarta.ws.rs.NotFoundException; import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response.Status; +import org.hamcrest.Matchers; import org.junit.Test; import org.keycloak.admin.client.resource.OrganizationMemberResource; import org.keycloak.admin.client.resource.OrganizationResource; +import org.keycloak.admin.client.resource.UserResource; import org.keycloak.common.Profile.Feature; import org.keycloak.models.utils.KeycloakModelUtils; +import org.keycloak.representations.idm.ErrorRepresentation; import org.keycloak.representations.idm.OrganizationRepresentation; -import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.representations.userprofile.config.UPConfig; import org.keycloak.representations.userprofile.config.UPConfig.UnmanagedAttributePolicy; +import org.keycloak.testsuite.Assert; import org.keycloak.testsuite.arquillian.annotation.EnableFeature; @EnableFeature(Feature.ORGANIZATION) @@ -53,14 +58,11 @@ public class OrganizationMemberTest extends AbstractOrganizationTest { expected.setFirstName("f"); expected.setLastName("l"); + expected.setEmail("some@differentthanorg.com"); - OrganizationMemberResource member = organization.members().member(expected.getId()); + testRealm().users().get(expected.getId()).update(expected); - try (Response response = member.update(expected)) { - assertEquals(Status.NO_CONTENT.getStatusCode(), response.getStatus()); - } - - UserRepresentation existing = member.toRepresentation(); + UserRepresentation existing = organization.members().member(expected.getId()).toRepresentation(); assertEquals(expected.getId(), existing.getId()); assertEquals(expected.getUsername(), existing.getUsername()); assertEquals(expected.getEmail(), existing.getEmail()); @@ -74,67 +76,44 @@ public class OrganizationMemberTest extends AbstractOrganizationTest { upConfig.setUnmanagedAttributePolicy(UnmanagedAttributePolicy.ENABLED); testRealm().users().userProfile().update(upConfig); OrganizationResource organization = testRealm().organizations().get(createOrganization().getId()); - UserRepresentation expected = new UserRepresentation(); + UserRepresentation expected = addMember(organization); + List expectedOrganizations = expected.getAttributes().get(ORGANIZATION_ATTRIBUTE); - expected.setUsername(expected.getEmail()); - expected.singleAttribute(USER_ORGANIZATION_ATTRIBUTE, "invalid"); + expected.singleAttribute(ORGANIZATION_ATTRIBUTE, "invalid"); - try (Response response = organization.members().addMember(expected)) { - assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus()); - assertTrue(testRealm().users().search("u@o.org").isEmpty()); + UserResource userResource = testRealm().users().get(expected.getId()); + + try { + userResource.update(expected); + Assert.fail("The attribute is readonly"); + } catch (BadRequestException bre) { + ErrorRepresentation error = bre.getResponse().readEntity(ErrorRepresentation.class); + assertEquals(ORGANIZATION_ATTRIBUTE, error.getField()); + assertEquals("error-user-attribute-read-only", error.getErrorMessage()); } + + // the attribute is readonly, removing it from the rep does not make any difference + expected.getAttributes().remove(ORGANIZATION_ATTRIBUTE); + userResource.update(expected); + expected = userResource.toRepresentation(); + assertThat(expected.getAttributes().get(ORGANIZATION_ATTRIBUTE), Matchers.containsInAnyOrder(expectedOrganizations.toArray())); + + userResource.update(expected); + expected = userResource.toRepresentation(); + assertThat(expected.getAttributes().get(ORGANIZATION_ATTRIBUTE), Matchers.containsInAnyOrder(expectedOrganizations.toArray())); } @Test - public void testFailSetEmailDomainOtherThanOrganizationDomain() { + public void testUserAlreadyMemberOfOrganization() { UPConfig upConfig = testRealm().users().userProfile().getConfiguration(); upConfig.setUnmanagedAttributePolicy(UnmanagedAttributePolicy.ENABLED); testRealm().users().userProfile().update(upConfig); OrganizationResource organization = testRealm().organizations().get(createOrganization().getId()); - UserRepresentation expected = new UserRepresentation(); + UserRepresentation expected = addMember(organization, KeycloakModelUtils.generateId() + "@user.org"); - expected.setUsername(KeycloakModelUtils.generateId() + "@user.org"); - expected.setEmail(expected.getUsername()); - - try (Response response = organization.members().addMember(expected)) { - assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus()); - assertTrue(testRealm().users().search(expected.getUsername()).isEmpty()); + try (Response response = organization.members().addMember(expected.getId())) { + assertEquals(Status.CONFLICT.getStatusCode(), response.getStatus()); } - - expected.setUsername(expected.getUsername().replace("@user.org", "@" + organizationName + ".org")); - expected.setEmail(expected.getUsername()); - - try (Response response = organization.members().addMember(expected)) { - assertEquals(Status.CREATED.getStatusCode(), response.getStatus()); - assertFalse(testRealm().users().search(expected.getUsername()).isEmpty()); - } - } - - @Test - public void testFailSetEmailDomainOtherThanOrganizationDomainViaUserApi() { - RealmRepresentation representation = testRealm().toRepresentation(); - representation.setEditUsernameAllowed(true); - testRealm().update(representation); - OrganizationRepresentation organization = createOrganization(); - UserRepresentation member = addMember(testRealm().organizations().get(organization.getId())); - - member.setUsername(KeycloakModelUtils.generateId() + "@user.org"); - member.setEmail(member.getUsername()); - member.setFirstName("f"); - member.setLastName("l"); - member.setEnabled(true); - - try { - testRealm().users().get(member.getId()).update(member); - fail("Should fail because email domain does not match any from organization"); - } catch (BadRequestException expected) { - - } - - member.setUsername(member.getUsername().replace("@user.org", "@" + organizationName + ".org")); - member.setEmail(member.getUsername()); - - testRealm().users().get(member.getId()).update(member); } @Test @@ -182,18 +161,27 @@ public class OrganizationMemberTest extends AbstractOrganizationTest { @Test public void testDelete() { + UPConfig upConfig = testRealm().users().userProfile().getConfiguration(); + upConfig.setUnmanagedAttributePolicy(UnmanagedAttributePolicy.ENABLED); OrganizationResource organization = testRealm().organizations().get(createOrganization().getId()); UserRepresentation expected = addMember(organization); + assertNotNull(expected.getAttributes()); + assertTrue(expected.getAttributes().get(ORGANIZATION_ATTRIBUTE).contains(organization.toRepresentation().getId())); OrganizationMemberResource member = organization.members().member(expected.getId()); try (Response response = member.delete()) { assertEquals(Status.NO_CONTENT.getStatusCode(), response.getStatus()); } + // user should exist but no longer an organization member + expected = testRealm().users().get(expected.getId()).toRepresentation(); + assertNull(expected.getAttributes()); try { member.toRepresentation(); - fail("should be deleted"); - } catch (NotFoundException ignore) {} + fail("should not be an organization member"); + } catch (NotFoundException ignore) { + + } } @Test @@ -214,11 +202,18 @@ public class OrganizationMemberTest extends AbstractOrganizationTest { } catch (NotFoundException ignore) {} } + for (UserRepresentation member : expected) { + // users should exist as they are not managed by the organization + testRealm().users().get(member.getId()).toRepresentation(); + } + for (UserRepresentation member : expected) { try { - testRealm().users().get(member.getId()).toRepresentation(); - fail("should be deleted"); - } catch (NotFoundException ignore) {} + // user no longer bound to the organization + organization.members().getOrganization(member.getId()); + fail("should not be associated with the organization anymore"); + } catch (NotFoundException ignore) { + } } } 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 555dd957fc..52f34f5786 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 @@ -18,7 +18,6 @@ package org.keycloak.testsuite.organization.admin; import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.empty; import static org.hamcrest.Matchers.equalTo; @@ -37,40 +36,19 @@ import java.util.HashMap; import java.util.List; import java.util.stream.Collectors; -import jakarta.ws.rs.ForbiddenException; import jakarta.ws.rs.NotFoundException; import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response.Status; -import org.hamcrest.Matchers; import org.junit.Test; -import org.keycloak.admin.client.Keycloak; import org.keycloak.admin.client.resource.OrganizationResource; -import org.keycloak.admin.client.resource.RealmResource; import org.keycloak.common.Profile.Feature; -import org.keycloak.models.AdminRoles; -import org.keycloak.models.Constants; -import org.keycloak.representations.idm.IdentityProviderRepresentation; import org.keycloak.representations.idm.OrganizationDomainRepresentation; import org.keycloak.representations.idm.OrganizationRepresentation; -import org.keycloak.representations.idm.RealmRepresentation; -import org.keycloak.representations.idm.UserRepresentation; -import org.keycloak.testsuite.admin.ApiUtil; import org.keycloak.testsuite.arquillian.annotation.EnableFeature; -import org.keycloak.testsuite.util.AdminClientUtil; -import org.keycloak.testsuite.util.UserBuilder; @EnableFeature(Feature.ORGANIZATION) public class OrganizationTest extends AbstractOrganizationTest { - @Override - public void configureTestRealm(RealmRepresentation testRealm) { - testRealm.getUsers().add(UserBuilder.create().username("realmAdmin").password("password") - .role(Constants.REALM_MANAGEMENT_CLIENT_ID, AdminRoles.MANAGE_REALM) - .role(Constants.REALM_MANAGEMENT_CLIENT_ID, AdminRoles.MANAGE_IDENTITY_PROVIDERS) - .role(Constants.REALM_MANAGEMENT_CLIENT_ID, AdminRoles.MANAGE_USERS) - .build()); - } - @Test public void testUpdate() { OrganizationRepresentation expected = createOrganization(); @@ -312,139 +290,4 @@ public class OrganizationTest extends AbstractOrganizationTest { assertEquals(1, existing.getDomains().size()); assertNotNull(existing.getDomain("acme.com")); } - - @Test - public void permissionsTest() throws Exception { - try ( - Keycloak manageRealmAdminClient = AdminClientUtil.createAdminClient(suiteContext.isAdapterCompatTesting(), - TEST_REALM_NAME, "realmAdmin", "password", Constants.ADMIN_CLI_CLIENT_ID, null); - Keycloak userAdminClient = AdminClientUtil.createAdminClient(suiteContext.isAdapterCompatTesting(), - TEST_REALM_NAME, "test-user@localhost", "password", Constants.ADMIN_CLI_CLIENT_ID, null) - ) { - RealmResource realmAdminResource = manageRealmAdminClient.realm(TEST_REALM_NAME); - RealmResource realmUserResource = userAdminClient.realm(TEST_REALM_NAME); - - /* Org */ - //create org - OrganizationRepresentation orgRep = createRepresentation("testOrg", "testOrg.org"); - String orgId; - try ( - Response userResponse = realmUserResource.organizations().create(orgRep); - Response adminResponse = realmAdminResource.organizations().create(orgRep) - ) { - assertThat(userResponse.getStatus(), equalTo(Status.FORBIDDEN.getStatusCode())); - assertThat(adminResponse.getStatus(), equalTo(Status.CREATED.getStatusCode())); - orgId = ApiUtil.getCreatedId(adminResponse); - getCleanup().addCleanup(() -> testRealm().organizations().get(orgId).delete().close()); - } - - //search for org - try { - realmUserResource.organizations().search("testOrg.org", true, 0, 1); - fail("Expected ForbiddenException"); - } catch (ForbiddenException expected) {} - assertThat(realmAdminResource.organizations().search("testOrg.org", true, 0, 1), Matchers.notNullValue()); - - //get org - try { - realmUserResource.organizations().get(orgId).toRepresentation(); - fail("Expected ForbiddenException"); - } catch (ForbiddenException expected) {} - assertThat(realmAdminResource.organizations().get(orgId).toRepresentation(), Matchers.notNullValue()); - - //update org - try (Response userResponse = realmUserResource.organizations().get(orgId).update(orgRep)) { - assertThat(userResponse.getStatus(), equalTo(Status.FORBIDDEN.getStatusCode())); - } - - //delete org - try (Response userResponse = realmUserResource.organizations().get(orgId).delete()) { - assertThat(userResponse.getStatus(), equalTo(Status.FORBIDDEN.getStatusCode())); - } - - /* IdP */ - IdentityProviderRepresentation idpRep = new IdentityProviderRepresentation(); - idpRep.setAlias("dummy"); - idpRep.setProviderId("oidc"); - //create IdP - try ( - Response userResponse = realmUserResource.organizations().get(orgId).identityProvider().create(idpRep); - Response adminResponse = realmAdminResource.organizations().get(orgId).identityProvider().create(idpRep) - ) { - assertThat(userResponse.getStatus(), equalTo(Status.FORBIDDEN.getStatusCode())); - assertThat(adminResponse.getStatus(), equalTo(Status.CREATED.getStatusCode())); - getCleanup().addCleanup(() -> testRealm().organizations().get(orgId).identityProvider().delete().close()); - } - - //get IdP - try { - //we should get 403, not 400 or 404 etc. - realmUserResource.organizations().get("non-existing").identityProvider().toRepresentation(); - fail("Expected ForbiddenException"); - } catch (ForbiddenException expected) {} - try { - realmUserResource.organizations().get(orgId).identityProvider().toRepresentation(); - fail("Expected ForbiddenException"); - } catch (ForbiddenException expected) {} - assertThat(realmAdminResource.organizations().get(orgId).identityProvider().toRepresentation(), Matchers.notNullValue()); - - //update IdP - try (Response userResponse = realmUserResource.organizations().get(orgId).identityProvider().update(idpRep)) { - assertThat(userResponse.getStatus(), equalTo(Status.FORBIDDEN.getStatusCode())); - } - - //delete IdP - try (Response userResponse = realmUserResource.organizations().get(orgId).identityProvider().delete()) { - assertThat(userResponse.getStatus(), equalTo(Status.FORBIDDEN.getStatusCode())); - } - - /* Members */ - UserRepresentation userRep = UserBuilder.create() - .username("user@testOrg.org") - .email("user@testOrg.org") - .build(); - String userId; - - //create member - try ( - Response userResponse = realmUserResource.organizations().get(orgId).members().addMember(userRep); - Response adminResponse = realmAdminResource.organizations().get(orgId).members().addMember(userRep) - ) { - assertThat(userResponse.getStatus(), equalTo(Status.FORBIDDEN.getStatusCode())); - assertThat(adminResponse.getStatus(), equalTo(Status.CREATED.getStatusCode())); - userId = ApiUtil.getCreatedId(adminResponse); - assertThat(userId, Matchers.notNullValue()); - getCleanup().addCleanup(() -> testRealm().organizations().get(orgId).members().member(userId).delete().close()); - } - - //get members - try { - //we should get 403, not 400 or 404 etc. - realmUserResource.organizations().get("non-existing").members().getAll(); - fail("Expected ForbiddenException"); - } catch (ForbiddenException expected) {} - try { - realmUserResource.organizations().get(orgId).members().getAll(); - fail("Expected ForbiddenException"); - } catch (ForbiddenException expected) {} - assertThat(realmAdminResource.organizations().get(orgId).members().getAll(), Matchers.notNullValue()); - - //get member - try { - realmUserResource.organizations().get(orgId).members().member(userId).toRepresentation(); - fail("Expected ForbiddenException"); - } catch (ForbiddenException expected) {} - assertThat(realmAdminResource.organizations().get(orgId).members().member(userId).toRepresentation(), Matchers.notNullValue()); - - //update member - try (Response userResponse = realmUserResource.organizations().get(orgId).members().member(userId).update(userRep)) { - assertThat(userResponse.getStatus(), equalTo(Status.FORBIDDEN.getStatusCode())); - } - - //delete member - try (Response userResponse = realmUserResource.organizations().get(orgId).members().member(userId).delete()) { - assertThat(userResponse.getStatus(), equalTo(Status.FORBIDDEN.getStatusCode())); - } - } - } }