diff --git a/model/src/main/java/org/cloudfoundry/identity/uaa/provider/IdentityProvider.java b/model/src/main/java/org/cloudfoundry/identity/uaa/provider/IdentityProvider.java index 329147958eb..76a47d37e37 100644 --- a/model/src/main/java/org/cloudfoundry/identity/uaa/provider/IdentityProvider.java +++ b/model/src/main/java/org/cloudfoundry/identity/uaa/provider/IdentityProvider.java @@ -121,7 +121,7 @@ public String getId() { @Override public String getZoneId() { - return identityZoneId; + return getIdentityZoneId(); } public IdentityProvider setId(String id) { diff --git a/model/src/main/java/org/cloudfoundry/identity/uaa/scim/ScimUser.java b/model/src/main/java/org/cloudfoundry/identity/uaa/scim/ScimUser.java index bdd298a4bba..7e66bc6df43 100644 --- a/model/src/main/java/org/cloudfoundry/identity/uaa/scim/ScimUser.java +++ b/model/src/main/java/org/cloudfoundry/identity/uaa/scim/ScimUser.java @@ -353,11 +353,9 @@ public int hashCode() { private String zoneId = null; - @JsonIgnore @Setter private String aliasZid = null; - @JsonIgnore @Setter private String aliasId = null; diff --git a/model/src/main/java/org/cloudfoundry/identity/uaa/scim/impl/ScimUserJsonDeserializer.java b/model/src/main/java/org/cloudfoundry/identity/uaa/scim/impl/ScimUserJsonDeserializer.java index d9524a7dc5f..fcd897f054b 100644 --- a/model/src/main/java/org/cloudfoundry/identity/uaa/scim/impl/ScimUserJsonDeserializer.java +++ b/model/src/main/java/org/cloudfoundry/identity/uaa/scim/impl/ScimUserJsonDeserializer.java @@ -87,6 +87,10 @@ public ScimUser deserialize(JsonParser jp, DeserializationContext ctxt) throws I user.setOrigin(jp.readValueAs(String.class)); } else if ("zoneId".equalsIgnoreCase(fieldName)) { user.setZoneId(jp.readValueAs(String.class)); + } else if ("aliasId".equalsIgnoreCase(fieldName)) { + user.setAliasId(jp.readValueAs(String.class)); + } else if ("aliasZid".equalsIgnoreCase(fieldName)) { + user.setAliasZid(jp.readValueAs(String.class)); } else if ("salt".equalsIgnoreCase(fieldName)) { user.setSalt(jp.readValueAs(String.class)); } else if ("passwordLastModified".equalsIgnoreCase(fieldName)) { diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimUserEndpoints.java b/server/src/main/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimUserEndpoints.java index d9d26fc78e7..83d6bdf9f1f 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimUserEndpoints.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimUserEndpoints.java @@ -3,6 +3,7 @@ import com.jayway.jsonpath.JsonPathException; import org.cloudfoundry.identity.uaa.account.UserAccountStatus; import org.cloudfoundry.identity.uaa.account.event.UserAccountUnlockedEvent; +import org.cloudfoundry.identity.uaa.alias.EntityAliasFailedException; import org.cloudfoundry.identity.uaa.approval.Approval; import org.cloudfoundry.identity.uaa.approval.ApprovalStore; import org.cloudfoundry.identity.uaa.audit.event.EntityDeletedEvent; @@ -26,6 +27,7 @@ import org.cloudfoundry.identity.uaa.scim.ScimGroup; import org.cloudfoundry.identity.uaa.scim.ScimGroupMembershipManager; import org.cloudfoundry.identity.uaa.scim.ScimUser; +import org.cloudfoundry.identity.uaa.scim.ScimUserAliasHandler; import org.cloudfoundry.identity.uaa.scim.ScimUserProvisioning; import org.cloudfoundry.identity.uaa.scim.exception.InvalidScimResourceException; import org.cloudfoundry.identity.uaa.scim.exception.ScimException; @@ -58,9 +60,12 @@ import org.springframework.jmx.export.annotation.ManagedResource; import org.springframework.jmx.support.MetricType; import org.springframework.lang.NonNull; +import org.springframework.lang.Nullable; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Controller; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; import org.springframework.util.Assert; import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.ExceptionHandler; @@ -84,12 +89,14 @@ import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; import static org.cloudfoundry.identity.uaa.codestore.ExpiringCodeType.REGISTRATION; +import static org.springframework.util.StringUtils.hasText; import static org.springframework.util.StringUtils.isEmpty; import lombok.Getter; @@ -122,12 +129,21 @@ public class ScimUserEndpoints implements InitializingBean, ApplicationEventPubl private final ExpiringCodeStore codeStore; private final ApprovalStore approvalStore; private final ScimGroupMembershipManager membershipManager; + private final boolean aliasEntitiesEnabled; @Getter private final int userMaxCount; private final HttpMessageConverter[] messageConverters; + /** + * Update operations performed on alias users are not considered. + */ private final AtomicInteger scimUpdates; + /** + * Deletion operations performed on alias users are not considered. + */ private final AtomicInteger scimDeletes; private final Map errorCounts; + private final ScimUserAliasHandler aliasHandler; + private final TransactionTemplate transactionTemplate; private ApplicationEventPublisher publisher; @@ -145,7 +161,11 @@ public ScimUserEndpoints( final ExpiringCodeStore codeStore, final ApprovalStore approvalStore, final ScimGroupMembershipManager membershipManager, - final @Value("${userMaxCount:500}") int userMaxCount) { + final ScimUserAliasHandler aliasHandler, + final TransactionTemplate transactionTemplate, + final @Qualifier("aliasEntitiesEnabled") boolean aliasEntitiesEnabled, + final @Value("${userMaxCount:500}") int userMaxCount + ) { if (userMaxCount <= 0) { throw new IllegalArgumentException( String.format("Invalid \"userMaxCount\" value (got %d). Should be positive number.", userMaxCount) @@ -161,11 +181,14 @@ public ScimUserEndpoints( this.passwordValidator = passwordValidator; this.codeStore = codeStore; this.approvalStore = approvalStore; + this.aliasEntitiesEnabled = aliasEntitiesEnabled; this.userMaxCount = userMaxCount; this.membershipManager = membershipManager; this.messageConverters = new HttpMessageConverter[] { new ExceptionReportHttpMessageConverter() }; + this.aliasHandler = aliasHandler; + this.transactionTemplate = transactionTemplate; scimUpdates = new AtomicInteger(); scimDeletes = new AtomicInteger(); errorCounts = new ConcurrentHashMap<>(); @@ -224,15 +247,52 @@ public ScimUser createUser(@RequestBody ScimUser user, HttpServletRequest reques passwordValidator.validate(user.getPassword()); } - ScimUser scimUser = scimUserProvisioning.createUser(user, user.getPassword(), identityZoneManager.getCurrentIdentityZoneId()); + user.setZoneId(identityZoneManager.getCurrentIdentityZoneId()); + + if (!aliasHandler.aliasPropertiesAreValid(user, null)) { + throw new ScimException("Alias ID and/or alias ZID are invalid.", HttpStatus.BAD_REQUEST); + } + + final ScimUser scimUser; + if (aliasEntitiesEnabled) { + // create the user and an alias for it if necessary + scimUser = createScimUserWithAliasHandling(user); + } else { + // create the user without alias handling + scimUser = scimUserProvisioning.createUser(user, user.getPassword(), identityZoneManager.getCurrentIdentityZoneId()); + } + if (user.getApprovals() != null) { for (Approval approval : user.getApprovals()) { approval.setUserId(scimUser.getId()); approvalStore.addApproval(approval, identityZoneManager.getCurrentIdentityZoneId()); } } - scimUser = syncApprovals(syncGroups(scimUser)); - addETagHeader(response, scimUser); + final ScimUser scimUserWithApprovalsAndGroups = syncApprovals(syncGroups(scimUser)); + addETagHeader(response, scimUserWithApprovalsAndGroups); + return scimUserWithApprovalsAndGroups; + } + + private ScimUser createScimUserWithAliasHandling(final ScimUser user) { + final ScimUser scimUser; + try { + scimUser = transactionTemplate.execute(txStatus -> { + final ScimUser originalScimUser = scimUserProvisioning.createUser( + user, + user.getPassword(), + identityZoneManager.getCurrentIdentityZoneId() + ); + return aliasHandler.ensureConsistencyOfAliasEntity( + originalScimUser, + null + ); + }); + } catch (final EntityAliasFailedException e) { + throw new ScimException(e.getMessage(), e, HttpStatus.resolve(e.getHttpStatus())); + } + if (scimUser == null) { + throw new IllegalStateException("The persisted user is not present after handling the alias."); + } return scimUser; } @@ -257,15 +317,47 @@ public ScimUser updateUser(@RequestBody ScimUser user, @PathVariable String user int version = getVersion(userId, etag); user.setVersion(version); + final ScimUser existingScimUser = scimUserProvisioning.retrieve( + userId, + identityZoneManager.getCurrentIdentityZoneId() + ); + if (!aliasHandler.aliasPropertiesAreValid(user, existingScimUser)) { + throw new ScimException("The fields 'aliasId' and/or 'aliasZid' are invalid.", HttpStatus.BAD_REQUEST); + } + + final ScimUser scimUser; try { - ScimUser updated = scimUserProvisioning.update(userId, user, identityZoneManager.getCurrentIdentityZoneId()); - scimUpdates.incrementAndGet(); - ScimUser scimUser = syncApprovals(syncGroups(updated)); - addETagHeader(httpServletResponse, scimUser); - return scimUser; - } catch (OptimisticLockingFailureException e) { + if (aliasEntitiesEnabled) { + // update user and create/update alias, if necessary + scimUser = updateUserWithAliasHandling(userId, user, existingScimUser); + } else { + // update user without alias handling + scimUser = scimUserProvisioning.update(userId, user, identityZoneManager.getCurrentIdentityZoneId()); + } + } catch (final OptimisticLockingFailureException e) { throw new ScimResourceConflictException(e.getMessage()); + } catch (final EntityAliasFailedException e) { + throw new ScimException(e.getMessage(), e, HttpStatus.resolve(e.getHttpStatus())); } + + scimUpdates.incrementAndGet(); + final ScimUser scimUserWithApprovalsAndGroups = syncApprovals(syncGroups(scimUser)); + addETagHeader(httpServletResponse, scimUserWithApprovalsAndGroups); + return scimUserWithApprovalsAndGroups; + } + + private ScimUser updateUserWithAliasHandling(final String userId, final ScimUser user, final ScimUser existingUser) { + return transactionTemplate.execute(txStatus -> { + final ScimUser updatedOriginalUser = scimUserProvisioning.update( + userId, + user, + identityZoneManager.getCurrentIdentityZoneId() + ); + return aliasHandler.ensureConsistencyOfAliasEntity( + updatedOriginalUser, + existingUser + ); + }); } @RequestMapping(value = "/Users/{userId}", method = RequestMethod.PATCH) @@ -298,6 +390,7 @@ public ScimUser patchUser(@RequestBody ScimUser patch, @PathVariable String user @RequestMapping(value = "/Users/{userId}", method = RequestMethod.DELETE) @ResponseBody + @Transactional public ScimUser deleteUser(@PathVariable String userId, @RequestHeader(value = "If-Match", required = false) String etag, HttpServletRequest request, @@ -305,6 +398,15 @@ public ScimUser deleteUser(@PathVariable String userId, int version = etag == null ? -1 : getVersion(userId, etag); ScimUser user = getUser(userId, httpServletResponse); throwWhenUserManagementIsDisallowed(user.getOrigin(), request); + + final boolean userHasAlias = hasText(user.getAliasZid()); + if (userHasAlias && !aliasEntitiesEnabled) { + throw new UaaException( + "Could not delete user with alias since alias entities are disabled.", + HttpStatus.BAD_REQUEST.value() + ); + } + membershipManager.removeMembersByMemberId(userId, identityZoneManager.getCurrentIdentityZoneId()); scimUserProvisioning.delete(userId, version, identityZoneManager.getCurrentIdentityZoneId()); scimDeletes.incrementAndGet(); @@ -315,8 +417,35 @@ public ScimUser deleteUser(@PathVariable String userId, SecurityContextHolder.getContext().getAuthentication(), identityZoneManager.getCurrentIdentityZoneId()) ); - logger.debug("User delete event sent[" + userId + "]"); + logger.debug("User delete event sent[{}]", userId); + } + + if (!userHasAlias) { + // no further action necessary + return user; } + + // also delete alias user, if present + final Optional aliasUserOpt = aliasHandler.retrieveAliasEntity(user); + if (aliasUserOpt.isEmpty()) { + // ignore dangling reference to alias user + logger.warn("Attempted to delete alias of user '{}', but it was not present.", user.getId()); + return user; + } + final ScimUser aliasUser = aliasUserOpt.get(); + membershipManager.removeMembersByMemberId(aliasUser.getId(), aliasUser.getZoneId()); + scimUserProvisioning.delete(aliasUser.getId(), aliasUser.getVersion(), aliasUser.getZoneId()); + if (publisher != null) { + publisher.publishEvent( + new EntityDeletedEvent<>( + aliasUser, + SecurityContextHolder.getContext().getAuthentication(), + aliasUser.getZoneId() + ) + ); + logger.debug("User delete event sent[{}]", userId); + } + return user; } @@ -413,7 +542,7 @@ public SearchResults findUsers( } } catch (IllegalArgumentException e) { String msg = "Invalid filter expression: [" + filter + "]"; - if (StringUtils.hasText(sortBy)) { + if (hasText(sortBy)) { msg += " [" + sortBy + "]"; } throw new ScimException(HtmlUtils.htmlEscape(msg), HttpStatus.BAD_REQUEST); @@ -469,9 +598,10 @@ public UserAccountStatus updateAccountStatus(@RequestBody UserAccountStatus stat return status; } - private ScimUser syncGroups(ScimUser user) { + @Nullable + private ScimUser syncGroups(@Nullable ScimUser user) { if (user == null) { - return user; + return null; } Set directGroups = membershipManager.getGroupsWithMember(user.getId(), false, identityZoneManager.getCurrentIdentityZoneId()); @@ -489,6 +619,9 @@ private ScimUser syncGroups(ScimUser user) { return user; } + /** + * Look up the approvals for the given user and keep only those that are currently active. + */ private ScimUser syncApprovals(ScimUser user) { if (user == null || approvalStore == null) { return user; diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/scim/jdbc/JdbcScimUserProvisioning.java b/server/src/main/java/org/cloudfoundry/identity/uaa/scim/jdbc/JdbcScimUserProvisioning.java index 793027b7bce..40dd5d7d83e 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/scim/jdbc/JdbcScimUserProvisioning.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/scim/jdbc/JdbcScimUserProvisioning.java @@ -22,9 +22,9 @@ import java.sql.Types; import java.util.Arrays; import java.util.Calendar; +import java.util.Collections; import java.util.Date; import java.util.GregorianCalendar; -import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.UUID; @@ -294,12 +294,8 @@ public ScimUser create(final ScimUser user, String zoneId) { }); } catch (DuplicateKeyException e) { String userOrigin = hasText(user.getOrigin()) ? user.getOrigin() : OriginKeys.UAA; - ScimUser existingUser = retrieveByUsernameAndOriginAndZone(user.getUserName(), userOrigin, zoneId).get(0); - Map userDetails = new HashMap<>(); - userDetails.put("active", existingUser.isActive()); - userDetails.put("verified", existingUser.isVerified()); - userDetails.put("user_id", existingUser.getId()); - throw new ScimResourceAlreadyExistsException("Username already in use: " + existingUser.getUserName(), userDetails); + Map userDetails = Collections.singletonMap("origin", userOrigin); + throw new ScimResourceAlreadyExistsException("Username already in use: " + user.getUserName(), userDetails); } return retrieve(id, zoneId); } diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/scim/bootstrap/ScimUserBootstrapTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/scim/bootstrap/ScimUserBootstrapTests.java index 756cd8308c1..c2494821483 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/scim/bootstrap/ScimUserBootstrapTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/scim/bootstrap/ScimUserBootstrapTests.java @@ -111,7 +111,12 @@ void init() throws SQLException { null, null, null, - jdbcScimGroupMembershipManager, 5); + jdbcScimGroupMembershipManager, + null, + null, + false, + 5 + ); IdentityZoneHolder.get().getConfig().getUserConfig().setDefaultGroups(emptyList()); } diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimUserEndpointsAliasTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimUserEndpointsAliasTests.java new file mode 100644 index 00000000000..4321c879019 --- /dev/null +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimUserEndpointsAliasTests.java @@ -0,0 +1,538 @@ +package org.cloudfoundry.identity.uaa.scim.endpoints; + +import org.apache.commons.lang3.tuple.Pair; +import org.cloudfoundry.identity.uaa.alias.EntityAliasFailedException; +import org.cloudfoundry.identity.uaa.approval.ApprovalStore; +import org.cloudfoundry.identity.uaa.audit.event.EntityDeletedEvent; +import org.cloudfoundry.identity.uaa.codestore.ExpiringCodeStore; +import org.cloudfoundry.identity.uaa.error.UaaException; +import org.cloudfoundry.identity.uaa.provider.IdentityProviderProvisioning; +import org.cloudfoundry.identity.uaa.resources.ResourceMonitor; +import org.cloudfoundry.identity.uaa.scim.ScimGroupMembershipManager; +import org.cloudfoundry.identity.uaa.scim.ScimUser; +import org.cloudfoundry.identity.uaa.scim.ScimUserAliasHandler; +import org.cloudfoundry.identity.uaa.scim.ScimUserProvisioning; +import org.cloudfoundry.identity.uaa.scim.exception.ScimException; +import org.cloudfoundry.identity.uaa.scim.validate.PasswordValidator; +import org.cloudfoundry.identity.uaa.security.IsSelfCheck; +import org.cloudfoundry.identity.uaa.util.AlphanumericRandomValueStringGenerator; +import org.cloudfoundry.identity.uaa.zone.beans.IdentityZoneManager; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.http.HttpStatus; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.support.TransactionCallback; +import org.springframework.transaction.support.TransactionTemplate; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.cloudfoundry.identity.uaa.constants.OriginKeys.UAA; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.util.StringUtils.hasText; + +@ExtendWith(MockitoExtension.class) +class ScimUserEndpointsAliasTests { + private static final AlphanumericRandomValueStringGenerator RANDOM_STRING_GENERATOR = new AlphanumericRandomValueStringGenerator(5); + + @Mock + private IdentityZoneManager identityZoneManager; + @Mock + private IsSelfCheck isSelfCheck; + @Mock + private ScimUserProvisioning scimUserProvisioning; + @Mock + private IdentityProviderProvisioning identityProviderProvisioning; + @Mock + private ResourceMonitor scimUserResourceMonitor; + @Mock + private PasswordValidator passwordValidator; + @Mock + private ExpiringCodeStore expiringCodeStore; + @Mock + private ApprovalStore approvalStore; + @Mock + private ScimGroupMembershipManager scimGroupMembershipManager; + @Mock + private ScimUserAliasHandler scimUserAliasHandler; + @Mock + private TransactionTemplate transactionTemplate; + @Mock + private ApplicationEventPublisher applicationEventPublisher; + + private ScimUserEndpoints scimUserEndpoints; + private String aliasZid; + private String origin; + + @BeforeEach + void setUp() { + scimUserEndpoints = new ScimUserEndpoints( + identityZoneManager, + isSelfCheck, + scimUserProvisioning, + identityProviderProvisioning, + scimUserResourceMonitor, + Collections.emptyMap(), + passwordValidator, + expiringCodeStore, + approvalStore, + scimGroupMembershipManager, + scimUserAliasHandler, + transactionTemplate, + true, // alias entities are enabled + 500 + ); + + aliasZid = RANDOM_STRING_GENERATOR.generate(); + origin = RANDOM_STRING_GENERATOR.generate(); + + // mock user creation -> adds new random ID + lenient().when(scimUserProvisioning.createUser( + any(ScimUser.class), + anyString(), + anyString() + )).then(invocationOnMock -> { + final String id = UUID.randomUUID().toString(); + final ScimUser scimUser = invocationOnMock.getArgument(0); + final String idzId = invocationOnMock.getArgument(2); + scimUser.setId(id); + scimUser.setZoneId(idzId); + return scimUser; + }); + + lenient().when(transactionTemplate.execute(any())).then(invocationOnMock -> { + final TransactionCallback callback = invocationOnMock.getArgument(0); + return callback.doInTransaction(mock(TransactionStatus.class)); + }); + } + + private void arrangeCurrentIdz(final String idzId) { + lenient().when(identityZoneManager.getCurrentIdentityZoneId()).thenReturn(idzId); + } + + private static ScimUser buildScimUser(final String idzId, final String origin) { + final ScimUser user = new ScimUser(); + user.setOrigin(origin); + final String email = "john.doe@example.com"; + user.setUserName(email); + user.setName(new ScimUser.Name("John", "Doe")); + user.setZoneId(idzId); + user.setPrimaryEmail(email); + return user; + } + + @Nested + class Create { + @BeforeEach + void setUp() { + arrangeCurrentIdz(UAA); + + // mock aliasHandler.ensureConsistencyOfAliasEntity -> adds random alias ID to original user + lenient().when(scimUserAliasHandler.ensureConsistencyOfAliasEntity( + any(ScimUser.class), + eq(null) + )).then(invocationOnMock -> { + final ScimUser scimUser = invocationOnMock.getArgument(0); + if (hasText(scimUser.getAliasZid())) { + // mock ID of newly created alias user + scimUser.setAliasId(UUID.randomUUID().toString()); + } + return scimUser; + }); + } + + @Test + void shouldThrow_WhenAliasPropertiesAreInvalid() { + final ScimUser user = buildScimUser(UAA, origin); + + when(scimUserAliasHandler.aliasPropertiesAreValid(user, null)).thenReturn(false); + + final MockHttpServletRequest req = new MockHttpServletRequest(); + final MockHttpServletResponse res = new MockHttpServletResponse(); + final ScimException exception = assertThrows(ScimException.class, () -> + scimUserEndpoints.createUser(user, req, res) + ); + assertThat(exception.getMessage()).isEqualTo("Alias ID and/or alias ZID are invalid."); + assertThat(exception.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + void shouldReturnOriginalUser() { + final ScimUser user = buildScimUser(UAA, origin); + user.setAliasZid(aliasZid); + + when(scimUserAliasHandler.aliasPropertiesAreValid(user, null)).thenReturn(true); + + final ScimUser response = scimUserEndpoints.createUser(user, new MockHttpServletRequest(), new MockHttpServletResponse()); + assertThat(response.getAliasId()).isNotBlank(); + } + + @Test + void shouldThrowScimException_WhenAliasCreationFailed() { + final ScimUser user = buildScimUser(UAA, origin); + user.setAliasZid(aliasZid); + + when(scimUserAliasHandler.aliasPropertiesAreValid(user, null)).thenReturn(true); + + final String errorMessage = "Creation of alias user failed."; + when(scimUserAliasHandler.ensureConsistencyOfAliasEntity(user, null)) + .thenThrow(new EntityAliasFailedException(errorMessage, 400, null)); + + final MockHttpServletRequest req = new MockHttpServletRequest(); + final MockHttpServletResponse res = new MockHttpServletResponse(); + final ScimException exception = assertThrows(ScimException.class, () -> + scimUserEndpoints.createUser(user, req, res) + ); + assertThat(exception.getMessage()).isEqualTo(errorMessage); + assertThat(exception.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + @Nested + class Update { + private final String currentZoneId = UAA; + private ScimUser originalUser; + private ScimUser existingOriginalUser; + + @BeforeEach + void setUp() { + arrangeCurrentIdz(currentZoneId); + + final Pair userAndAlias = buildUserAndAlias(origin, currentZoneId, aliasZid); + originalUser = userAndAlias.getLeft(); + existingOriginalUser = cloneScimUser(originalUser); + existingOriginalUser.setVersion(1); + originalUser.setName(new ScimUser.Name("some-new-given-name", "some-new-family-name")); + when(scimUserProvisioning.retrieve(originalUser.getId(), currentZoneId)).thenReturn(existingOriginalUser); + } + + @Test + void shouldThrow_IfAliasPropertiesAreInvalid() { + when(scimUserAliasHandler.aliasPropertiesAreValid(originalUser, existingOriginalUser)) + .thenReturn(false); + + final ScimException exception = assertThrows(ScimException.class, () -> + scimUserEndpoints.updateUser( + originalUser, + originalUser.getId(), + "*", + new MockHttpServletRequest(), + new MockHttpServletResponse(), + null + ) + ); + assertThat(exception.getMessage()).isEqualTo("The fields 'aliasId' and/or 'aliasZid' are invalid."); + assertThat(exception.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + void shouldAlsoUpdateAliasUserIfPresent() { + when(scimUserAliasHandler.aliasPropertiesAreValid(originalUser, existingOriginalUser)) + .thenReturn(true); + + // mock update -> increments version + when(scimUserProvisioning.update(originalUser.getId(), originalUser, currentZoneId)) + .then(invocationOnMock -> { + final ScimUser user = invocationOnMock.getArgument(1); + user.setVersion(user.getVersion() + 1); + return user; + }); + + // mock aliasHandler.ensureConsistency -> no changes to original user + when(scimUserAliasHandler.ensureConsistencyOfAliasEntity(originalUser, existingOriginalUser)) + .then(invocationOnMock -> invocationOnMock.getArgument(0)); + + final ScimUser result = scimUserEndpoints.updateUser( + originalUser, + originalUser.getId(), + "*", + new MockHttpServletRequest(), + new MockHttpServletResponse(), + null + ); + assertScimUsersAreEqual(result, originalUser); + } + + @Test + void shouldThrowScimException_IfAliasHandlerThrows() { + when(scimUserAliasHandler.aliasPropertiesAreValid(originalUser, existingOriginalUser)) + .thenReturn(true); + + // mock update -> increments version + when(scimUserProvisioning.update(originalUser.getId(), originalUser, currentZoneId)) + .then(invocationOnMock -> { + final ScimUser user = invocationOnMock.getArgument(1); + user.setVersion(user.getVersion() + 1); + return user; + }); + + // mock aliasHandler.ensureConsistency -> should throw exception + final String errorMessage = "Could not create alias."; + when(scimUserAliasHandler.ensureConsistencyOfAliasEntity(originalUser, existingOriginalUser)) + .thenThrow(new EntityAliasFailedException(errorMessage, 400, null)); + + final ScimException exception = assertThrows(ScimException.class, () -> + scimUserEndpoints.updateUser( + originalUser, + originalUser.getId(), + "*", + new MockHttpServletRequest(), + new MockHttpServletResponse(), + null + ) + ); + assertThat(exception.getMessage()).isEqualTo(errorMessage); + assertThat(exception.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + @Nested + class Delete { + @BeforeEach + void setUp() { + scimUserEndpoints.setApplicationEventPublisher(applicationEventPublisher); + } + + @Nested + class AliasFeatureEnabled { + private ScimUser originalUser; + private ScimUser aliasUser; + + @BeforeEach + void setUp() { + arrangeCurrentIdz(UAA); + + final Pair userAndAlias = buildUserAndAlias(origin, UAA, aliasZid); + originalUser = userAndAlias.getLeft(); + originalUser.setVersion(2); + when(scimUserProvisioning.retrieve(originalUser.getId(), UAA)).thenReturn(originalUser); + + aliasUser = userAndAlias.getRight(); + } + + @Test + void shouldAlsoDeleteAliasUserIfPresent() { + when(scimUserAliasHandler.retrieveAliasEntity(originalUser)).thenReturn(Optional.of(aliasUser)); + + final ScimUser response = scimUserEndpoints.deleteUser( + originalUser.getId(), + null, + new MockHttpServletRequest(), + new MockHttpServletResponse() + ); + + assertScimUsersAreEqual(response, originalUser); + + assertOriginalAndAliasUserAreRemovedFromGroups(originalUser.getId(), UAA, aliasUser.getId(), aliasZid); + assertOriginalAndAliasUsersAreDeleted(originalUser.getId(), UAA, aliasUser.getId(), aliasZid, aliasUser.getVersion()); + assertEventIsPublishedForOriginalAndAliasUser(UAA, originalUser, aliasZid, aliasUser); + } + + @Test + void shouldIgnore_ReferencedAliasUserNotPresent() { + // arrange referenced alias user is not present + when(scimUserAliasHandler.retrieveAliasEntity(originalUser)).thenReturn(Optional.empty()); + + final ScimUser response = scimUserEndpoints.deleteUser( + originalUser.getId(), + null, + new MockHttpServletRequest(), + new MockHttpServletResponse() + ); + + assertScimUsersAreEqual(response, originalUser); + + verify(scimGroupMembershipManager).removeMembersByMemberId(originalUser.getId(), UAA); + verify(scimUserProvisioning).delete(originalUser.getId(), -1, UAA); + final ArgumentCaptor> eventArgument = ArgumentCaptor.forClass(EntityDeletedEvent.class); + verify(applicationEventPublisher).publishEvent(eventArgument.capture()); + final EntityDeletedEvent capturedEvent = eventArgument.getValue(); + assertThat(capturedEvent.getIdentityZoneId()).isEqualTo(UAA); + assertScimUsersAreEqual(capturedEvent.getDeleted(), originalUser); + } + + private void assertOriginalAndAliasUserAreRemovedFromGroups( + final String userId, + final String zoneId, + final String aliasId, + final String aliasZid + ) { + final ArgumentCaptor memberIdArgument = ArgumentCaptor.forClass(String.class); + final ArgumentCaptor zoneIdArgument = ArgumentCaptor.forClass(String.class); + verify(scimGroupMembershipManager, times(2)).removeMembersByMemberId( + memberIdArgument.capture(), + zoneIdArgument.capture() + ); + final List capturedMemberIds = memberIdArgument.getAllValues(); + assertThat(capturedMemberIds.get(0)).isEqualTo(userId); + assertThat(capturedMemberIds.get(1)).isEqualTo(aliasId); + final List capturedZoneIds = zoneIdArgument.getAllValues(); + assertThat(capturedZoneIds.get(0)).isEqualTo(zoneId); + assertThat(capturedZoneIds.get(1)).isEqualTo(aliasZid); + } + + private void assertEventIsPublishedForOriginalAndAliasUser( + final String zoneId, + final ScimUser originalUser, + final String aliasZid, + final ScimUser aliasUser + ) { + final ArgumentCaptor> eventArgument = ArgumentCaptor.forClass(EntityDeletedEvent.class); + verify(applicationEventPublisher, times(2)).publishEvent(eventArgument.capture()); + final List> capturedEvents = eventArgument.getAllValues(); + final EntityDeletedEvent eventForOriginalUser = capturedEvents.get(0); + assertThat(eventForOriginalUser.getIdentityZoneId()).isEqualTo(zoneId); + assertScimUsersAreEqual(eventForOriginalUser.getDeleted(), originalUser); + final EntityDeletedEvent eventForAliasUser = capturedEvents.get(1); + assertThat(eventForAliasUser.getIdentityZoneId()).isEqualTo(aliasZid); + assertScimUsersAreEqual(eventForAliasUser.getDeleted(), aliasUser); + } + + private void assertOriginalAndAliasUsersAreDeleted( + final String userId, + final String zoneId, + final String aliasId, + final String aliasZid, + final int aliasUserVersion + ) { + final ArgumentCaptor userIdArgument = ArgumentCaptor.forClass(String.class); + final ArgumentCaptor versionArgument = ArgumentCaptor.forClass(Integer.class); + final ArgumentCaptor zoneIdArgument = ArgumentCaptor.forClass(String.class); + verify(scimUserProvisioning, times(2)).delete( + userIdArgument.capture(), + versionArgument.capture(), + zoneIdArgument.capture() + ); + final List capturedUserIds = userIdArgument.getAllValues(); + assertThat(capturedUserIds.get(0)).isEqualTo(userId); + assertThat(capturedUserIds.get(1)).isEqualTo(aliasId); + final List capturedVersions = versionArgument.getAllValues(); + assertThat(capturedVersions.get(0)) + .isEqualTo(-1); // etag in scimUserEndpoints.deleteUser call is null + assertThat(capturedVersions.get(1)).isEqualTo(aliasUserVersion); + final List capturedZoneIds2 = zoneIdArgument.getAllValues(); + assertThat(capturedZoneIds2.get(0)).isEqualTo(zoneId); + assertThat(capturedZoneIds2.get(1)).isEqualTo(aliasZid); + } + } + + @Nested + class AliasFeatureDisabled { + @BeforeEach + void setUp() { + arrangeAliasFeatureIsEnabled(false); + } + + @AfterEach + void tearDown() { + arrangeAliasFeatureIsEnabled(true); + } + + @Test + void shouldThrowException_IfUserHasExistingAlias() { + arrangeCurrentIdz(UAA); + + final Pair userAndAlias = buildUserAndAlias(origin, UAA, aliasZid); + final ScimUser originalUser = userAndAlias.getLeft(); + originalUser.setVersion(2); + when(scimUserProvisioning.retrieve(originalUser.getId(), UAA)).thenReturn(originalUser); + + final MockHttpServletRequest req = new MockHttpServletRequest(); + final MockHttpServletResponse res = new MockHttpServletResponse(); + final UaaException exception = assertThrows(UaaException.class, () -> + scimUserEndpoints.deleteUser(originalUser.getId(), null, req, res) + ); + assertThat(exception.getMessage()) + .isEqualTo("Could not delete user with alias since alias entities are disabled."); + assertThat(exception.getHttpStatus()).isEqualTo(HttpStatus.BAD_REQUEST.value()); + } + } + } + + private void arrangeAliasFeatureIsEnabled(final boolean enabled) { + ReflectionTestUtils.setField(scimUserEndpoints, "aliasEntitiesEnabled", enabled); + } + + /** + * This method is required because the {@link ScimUser} class does not implement an adequate {@code equals} method. + */ + private static void assertScimUsersAreEqual(final ScimUser actual, final ScimUser expected) { + assertThat(actual.getId()).isEqualTo(expected.getId()); + assertThat(actual.getExternalId()).isEqualTo(expected.getExternalId()); + assertThat(actual.getOrigin()).isEqualTo(expected.getOrigin()); + + assertThat(actual.getUserName()).isEqualTo(expected.getUserName()); + assertThat(actual.getName()).isEqualTo(expected.getName()); + + assertThat(actual.getEmails()).hasSameElementsAs(expected.getEmails()); + + assertThat(actual.getZoneId()).isEqualTo(expected.getZoneId()); + assertThat(actual.getAliasId()).isEqualTo(expected.getAliasId()); + assertThat(actual.getAliasZid()).isEqualTo(expected.getAliasZid()); + + assertThat(actual.getLastLogonTime()).isEqualTo(expected.getLastLogonTime()); + assertThat(actual.getPreviousLogonTime()).isEqualTo(expected.getPreviousLogonTime()); + assertThat(actual.getPasswordLastModified()).isEqualTo(expected.getPasswordLastModified()); + + assertThat(actual.isActive()).isEqualTo(expected.isActive()); + assertThat(actual.isVerified()).isEqualTo(expected.isVerified()); + } + + private static Pair buildUserAndAlias( + final String origin, + final String zoneId, + final String aliasZid + ) { + final ScimUser originalUser = buildScimUser(zoneId, origin); + final String userId = UUID.randomUUID().toString(); + originalUser.setId(userId); + originalUser.setAliasZid(aliasZid); + final String aliasId = UUID.randomUUID().toString(); + originalUser.setAliasId(aliasId); + + final ScimUser aliasUser = buildScimUser(aliasZid, origin); + aliasUser.setId(aliasId); + aliasUser.setAliasId(userId); + aliasUser.setAliasZid(zoneId); + + return Pair.of(originalUser, aliasUser); + } + + private static ScimUser cloneScimUser(final ScimUser scimUser) { + final ScimUser clonedScimUser = new ScimUser(); + clonedScimUser.setId(scimUser.getId()); + clonedScimUser.setUserName(scimUser.getUserName()); + clonedScimUser.setPrimaryEmail(scimUser.getPrimaryEmail()); + clonedScimUser.setName(scimUser.getName()); + clonedScimUser.setActive(scimUser.isActive()); + clonedScimUser.setPhoneNumbers(scimUser.getPhoneNumbers()); + clonedScimUser.setOrigin(scimUser.getOrigin()); + clonedScimUser.setAliasId(scimUser.getAliasId()); + clonedScimUser.setAliasZid(scimUser.getAliasZid()); + clonedScimUser.setZoneId(scimUser.getZoneId()); + clonedScimUser.setPassword(scimUser.getPassword()); + clonedScimUser.setSalt(scimUser.getSalt()); + return clonedScimUser; + } +} diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/scim/jdbc/JdbcScimUserProvisioningTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/scim/jdbc/JdbcScimUserProvisioningTests.java index 2b5e8abb8dd..09c8e37cfd4 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/scim/jdbc/JdbcScimUserProvisioningTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/scim/jdbc/JdbcScimUserProvisioningTests.java @@ -1049,9 +1049,7 @@ void createUserWithDuplicateUsername() { () -> jdbcScimUserProvisioning.create(scimUser, currentIdentityZoneId)); Map userDetails = new HashMap<>(); - userDetails.put("active", true); - userDetails.put("verified", false); - userDetails.put("user_id", "cba09242-aa43-4247-9aa0-b5c75c281f94"); + userDetails.put("origin", UAA); assertEquals(HttpStatus.CONFLICT, e.getStatus()); assertEquals("Username already in use: user@example.com", e.getMessage()); assertEquals(userDetails, e.getExtraInfo()); diff --git a/uaa/slateCustomizations/source/index.html.md.erb b/uaa/slateCustomizations/source/index.html.md.erb index 792a2ce97b3..86a4d05e582 100644 --- a/uaa/slateCustomizations/source/index.html.md.erb +++ b/uaa/slateCustomizations/source/index.html.md.erb @@ -1382,12 +1382,13 @@ _Response Fields_ _Error Codes_ -| Error Code | Description | -|------------|--------------------------------------------------------------------------------------------------------| -| 400 | Bad Request - Invalid JSON format or missing fields | -| 401 | Unauthorized - Invalid token | -| 403 | Forbidden - Insufficient scope (`scim.write` is required to create a user) | -| 409 | Conflict - Username already exists | +| Error Code | Description | +|------------|-------------------------------------------------------------------------------------| +| 400 | Bad Request - Invalid JSON format or missing fields | +| 401 | Unauthorized - Invalid token | +| 403 | Forbidden - Insufficient scope (`scim.write` is required to create a user) | +| 409 | Conflict - Username already exists | +| 422 | Unprocessable Entity - `alias_zid` set, but error occurred during creation of alias | >Example using uaac to view users: @@ -1423,12 +1424,13 @@ _Response Fields_ _Error Codes_ -| Error Code | Description | -|------------|--------------------------------------------------------------------------------------------------------| -| 400 | Bad Request - Invalid JSON format or missing fields | -| 401 | Unauthorized - Invalid token | -| 403 | Forbidden - Insufficient scope (`scim.write` is required to update a user) | -| 404 | Not Found - User id not found | +| Error Code | Description | +|------------|---------------------------------------------------------------------------------------------------------------------------| +| 400 | Bad Request - Invalid JSON format or missing fields; trying to update user with alias while `aliasEntitiesEnabled` is off | +| 401 | Unauthorized - Invalid token | +| 403 | Forbidden - Insufficient scope (`scim.write` is required to update a user) | +| 404 | Not Found - User id not found | +| 422 | Unprocessable Entity - error occurred during creation or update of alias | >Example using uaac to view users: @@ -1464,12 +1466,12 @@ _Response Fields_ _Error Codes_ -| Error Code | Description | -|------------|--------------------------------------------------------------------------------------------------------| -| 400 | Bad Request - Invalid JSON format or missing fields | -| 401 | Unauthorized - Invalid token | -| 403 | Forbidden - Insufficient scope (`scim.write` is required to update a user) | -| 404 | Not Found - User id not found | +| Error Code | Description | +|------------|---------------------------------------------------------------------------------------------------------------------------| +| 400 | Bad Request - Invalid JSON format or missing fields; trying to update user with alias while `aliasEntitiesEnabled` is off | +| 401 | Unauthorized - Invalid token | +| 403 | Forbidden - Insufficient scope (`scim.write` is required to update a user) | +| 404 | Not Found - User id not found | >Example using uaac to patch users: @@ -1497,12 +1499,12 @@ _Response Fields_ _Error Codes_ -| Error Code | Description | -|------------|--------------------------------------------------------------------------------------------------------| -| 400 | Bad Request - Invalid JSON format or missing fields | -| 401 | Unauthorized - Invalid token | -| 403 | Forbidden - Insufficient scope (`scim.write` is required to delete a user) | -| 404 | Not Found - User id not found | +| Error Code | Description | +|------------|---------------------------------------------------------------------------------------------------------------------------| +| 400 | Bad Request - Invalid JSON format or missing fields; trying to delete user with alias while `aliasEntitiesEnabled` is off | +| 401 | Unauthorized - Invalid token | +| 403 | Forbidden - Insufficient scope (`scim.write` is required to delete a user) | +| 404 | Not Found - User id not found | >Example using uaac to delete users: diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/alias/AliasMockMvcTestBase.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/alias/AliasMockMvcTestBase.java new file mode 100644 index 00000000000..1e4208ef721 --- /dev/null +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/alias/AliasMockMvcTestBase.java @@ -0,0 +1,225 @@ +package org.cloudfoundry.identity.uaa.alias; + +import org.cloudfoundry.identity.uaa.mock.util.MockMvcUtils; +import org.cloudfoundry.identity.uaa.oauth.token.Claims; +import org.cloudfoundry.identity.uaa.oauth.token.TokenConstants; +import org.cloudfoundry.identity.uaa.provider.AbstractIdentityProviderDefinition; +import org.cloudfoundry.identity.uaa.provider.IdentityProvider; +import org.cloudfoundry.identity.uaa.provider.OIDCIdentityProviderDefinition; +import org.cloudfoundry.identity.uaa.provider.PasswordPolicy; +import org.cloudfoundry.identity.uaa.provider.UaaIdentityProviderDefinition; +import org.cloudfoundry.identity.uaa.scim.ScimUser; +import org.cloudfoundry.identity.uaa.test.TestClient; +import org.cloudfoundry.identity.uaa.util.AlphanumericRandomValueStringGenerator; +import org.cloudfoundry.identity.uaa.util.JsonUtils; +import org.cloudfoundry.identity.uaa.util.UaaTokenUtils; +import org.cloudfoundry.identity.uaa.zone.IdentityZone; +import org.cloudfoundry.identity.uaa.zone.IdentityZoneSwitchingFilter; +import org.cloudfoundry.identity.uaa.zone.JdbcIdentityZoneProvisioning; +import org.junit.jupiter.api.function.ThrowingSupplier; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; +import org.springframework.web.context.WebApplicationContext; + +import java.net.MalformedURLException; +import java.net.URL; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.cloudfoundry.identity.uaa.constants.OriginKeys.OIDC10; +import static org.cloudfoundry.identity.uaa.constants.OriginKeys.UAA; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; + +public abstract class AliasMockMvcTestBase { + protected static final AlphanumericRandomValueStringGenerator RANDOM_STRING_GENERATOR = new AlphanumericRandomValueStringGenerator(8); + private final Map accessTokenCache = new HashMap<>(); + + @Autowired + protected WebApplicationContext webApplicationContext; + @Autowired + protected MockMvc mockMvc; + @Autowired + private TestClient testClient; + + protected IdentityZone customZone; + protected IdentityZone uaaZone; + private String adminToken; + protected String identityToken; + + protected final void setUpTokensAndCustomZone() throws Exception { + adminToken = testClient.getClientCredentialsOAuthAccessToken( + "admin", + "adminsecret", + ""); + identityToken = testClient.getClientCredentialsOAuthAccessToken( + "identity", + "identitysecret", + "zones.write"); + customZone = MockMvcUtils.createZoneUsingWebRequest(mockMvc, identityToken); + + // look up UAA zone + final JdbcIdentityZoneProvisioning zoneProvisioning = webApplicationContext.getBean( + JdbcIdentityZoneProvisioning.class + ); + uaaZone = zoneProvisioning.retrieve(IdentityZone.getUaaZoneId()); + } + + protected static AbstractIdentityProviderDefinition buildIdpDefinition(final String type) { + switch (type) { + case OIDC10: + final OIDCIdentityProviderDefinition definition = new OIDCIdentityProviderDefinition(); + try { + return definition + .setAuthUrl(new URL("https://www.example.com/oauth/authorize")) + .setLinkText("link text") + .setRelyingPartyId("relying-party-id") + .setRelyingPartySecret("relying-party-secret") + .setShowLinkText(true) + .setSkipSslValidation(true) + .setTokenKey("key") + .setTokenKeyUrl(new URL("https://www.example.com/token_keys")) + .setTokenUrl(new URL("https://wwww.example.com/oauth/token")); + } catch (final MalformedURLException e) { + throw new RuntimeException(e); + } + case UAA: + final PasswordPolicy passwordPolicy = new PasswordPolicy(); + passwordPolicy.setExpirePasswordInMonths(1); + passwordPolicy.setMaxLength(100); + passwordPolicy.setMinLength(10); + passwordPolicy.setRequireDigit(1); + passwordPolicy.setRequireUpperCaseCharacter(1); + passwordPolicy.setRequireLowerCaseCharacter(1); + passwordPolicy.setRequireSpecialCharacter(1); + passwordPolicy.setPasswordNewerThan(new Date(System.currentTimeMillis())); + return new UaaIdentityProviderDefinition(passwordPolicy, null); + default: + throw new IllegalArgumentException("IdP type not supported."); + } + } + + protected static IdentityProvider buildIdpWithAliasProperties( + final String idzId, + final String aliasId, + final String aliasZid, + final String originKey, + final String type + ) { + final AbstractIdentityProviderDefinition definition = buildIdpDefinition(type); + + final IdentityProvider provider = new IdentityProvider<>(); + provider.setIdentityZoneId(idzId); + provider.setAliasId(aliasId); + provider.setAliasZid(aliasZid); + provider.setName(originKey); + provider.setOriginKey(originKey); + provider.setType(type); + provider.setConfig(definition); + provider.setActive(true); + return provider; + } + + protected static IdentityProvider buildOidcIdpWithAliasProperties( + final String idzId, + final String aliasId, + final String aliasZid + ) { + final String originKey = RANDOM_STRING_GENERATOR.generate(); + return buildIdpWithAliasProperties(idzId, aliasId, aliasZid, originKey, OIDC10); + } + + protected static List getScopesForZone(final String zoneId, final String... scopes) { + return Stream.of(scopes).map(scope -> String.format("zones.%s.%s", zoneId, scope)).toList(); + } + + protected static IdentityProvider buildUaaIdpWithAliasProperties( + final String idzId, + final String aliasId, + final String aliasZid + ) { + final String originKey = RANDOM_STRING_GENERATOR.generate(); + return buildIdpWithAliasProperties(idzId, aliasId, aliasZid, originKey, UAA); + } + + protected final T executeWithTemporarilyEnabledAliasFeature( + final boolean aliasFeatureEnabledBeforeAction, + final ThrowingSupplier action + ) throws Throwable { + arrangeAliasFeatureEnabled(true); + try { + return action.get(); + } finally { + arrangeAliasFeatureEnabled(aliasFeatureEnabledBeforeAction); + } + } + + protected final String getAccessTokenForZone(final String zoneId) throws Exception { + final String cacheLookupResult = accessTokenCache.get(zoneId); + if (cacheLookupResult != null) { + return cacheLookupResult; + } + + final List scopesForZone = getScopesForZone(zoneId, "admin"); + + final ScimUser adminUser = MockMvcUtils.createAdminForZone( + mockMvc, + adminToken, + String.join(",", scopesForZone), + IdentityZone.getUaaZoneId() + ); + final String accessToken = MockMvcUtils.getUserOAuthAccessTokenAuthCode( + mockMvc, + "identity", + "identitysecret", + adminUser.getId(), + adminUser.getUserName(), + adminUser.getPassword(), + String.join(" ", scopesForZone), + IdentityZone.getUaaZoneId(), + TokenConstants.TokenFormat.JWT // use JWT for later checking if all scopes are present + ); + + // check if the token contains the expected scopes + final Claims claims = UaaTokenUtils.getClaimsFromTokenString(accessToken); + assertThat(claims.getScope()).hasSameElementsAs(scopesForZone); + + // cache the access token + accessTokenCache.put(zoneId, accessToken); + + return accessToken; + } + + protected final MvcResult createIdpAndReturnResult(final IdentityZone zone, final IdentityProvider idp) throws Exception { + final MockHttpServletRequestBuilder createRequestBuilder = post("/identity-providers") + .param("rawConfig", "true") + .header("Authorization", "Bearer " + getAccessTokenForZone(zone.getId())) + .header(IdentityZoneSwitchingFilter.SUBDOMAIN_HEADER, zone.getSubdomain()) + .contentType(APPLICATION_JSON) + .content(JsonUtils.writeValueAsString(idp)); + return mockMvc.perform(createRequestBuilder).andReturn(); + } + + protected final IdentityProvider createIdp(final IdentityZone zone, final IdentityProvider idp) throws Exception { + final MvcResult createResult = createIdpAndReturnResult(zone, idp); + assertThat(createResult.getResponse().getStatus()).isEqualTo(HttpStatus.CREATED.value()); + return JsonUtils.readValue(createResult.getResponse().getContentAsString(), IdentityProvider.class); + } + + protected final IdentityProvider createIdpWithAlias(final IdentityZone zone1, final IdentityZone zone2) throws Exception { + final IdentityProvider provider = buildOidcIdpWithAliasProperties(zone1.getId(), null, zone2.getId()); + final IdentityProvider createdOriginalIdp = createIdp(zone1, provider); + assertThat(createdOriginalIdp.getAliasId()).isNotBlank(); + assertThat(createdOriginalIdp.getAliasZid()).isNotBlank(); + return createdOriginalIdp; + } + + protected abstract void arrangeAliasFeatureEnabled(final boolean enabled); +} diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimUserEndpointDocs.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimUserEndpointDocs.java index d0feba11750..34a69a43827 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimUserEndpointDocs.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimUserEndpointDocs.java @@ -93,6 +93,18 @@ class ScimUserEndpointDocs extends EndpointDocs { private final String passwordDescription = "User's password, required if origin is set to `uaa`. May be be subject to validations if the UAA is configured with a password policy."; private final String phoneNumbersListDescription = "The user's phone numbers."; private final String phoneNumbersDescription = "The phone number."; + private final String aliasIdDescription = "The ID of the alias user."; + private final String aliasIdCreateRequestDescription = aliasIdDescription + " Must be set to `null`."; + private final String aliasIdUpdateRequestDescription = aliasIdDescription + " If the existing user had this field set, it must be set to the same value in the update request. " + + "If not, this field must be set to `null`."; + private final String aliasIdPatchRequestDescription = aliasIdDescription + " If set, this field must have the same value as in the existing user."; + private final String aliasZidDescription = "The ID of the identity zone in which an alias of this user is maintained."; + private final String aliasZidRequestDescription = aliasZidDescription + " If set, an alias user is created in this zone and `aliasId` is set accordingly. " + + "Must reference an existing identity zone that is different to the one referenced in `identityZoneId`. " + + "Alias users can only be created from or to the \"uaa\" identity zone, i.e., one of `identityZoneId` or `aliasZid` must be set to \"uaa\". " + + "Furthermore, alias users can only be created if the IdP referenced in `origin` also has an alias to the **same** zone as the user."; + private final String aliasZidUpdateRequestDescription = aliasZidRequestDescription + " If the existing user had this field set, it must be set to the same value in the update request."; + private final String aliasZidPatchRequestDescription = aliasZidRequestDescription + " If the existing user had this field set, it must not be set to a different value in the patch request."; private final String metaDesc = "SCIM object meta data."; private final String metaVersionDesc = "Object version."; @@ -140,6 +152,8 @@ class ScimUserEndpointDocs extends EndpointDocs { fieldWithPath("resources[].zoneId").type(STRING).description(userZoneIdDescription), fieldWithPath("resources[].passwordLastModified").type(STRING).description(passwordLastModifiedDescription), fieldWithPath("resources[].externalId").type(STRING).description(externalIdDescription), + fieldWithPath("resources[].aliasId").optional(null).type(STRING).description(aliasIdDescription), + fieldWithPath("resources[].aliasZid").optional(null).type(STRING).description(aliasZidDescription), fieldWithPath("resources[].meta").type(OBJECT).description(metaDesc), fieldWithPath("resources[].meta.version").type(NUMBER).description(metaVersionDesc), fieldWithPath("resources[].meta.lastModified").type(STRING).description(metaLastModifiedDesc), @@ -162,6 +176,8 @@ class ScimUserEndpointDocs extends EndpointDocs { fieldWithPath("verified").optional(true).type(BOOLEAN).description(userVerifiedDescription), fieldWithPath("origin").optional(OriginKeys.UAA).type(STRING).description(userOriginDescription), fieldWithPath("externalId").optional(null).type(STRING).description(externalIdDescription), + fieldWithPath("aliasId").optional(null).type(STRING).description(aliasIdCreateRequestDescription), + fieldWithPath("aliasZid").optional(null).type(STRING).description(aliasZidRequestDescription), fieldWithPath("schemas").optional().ignored().type(ARRAY).description(schemasDescription), fieldWithPath("meta.*").optional().ignored().type(OBJECT).description("SCIM object meta data not read.") ); @@ -189,6 +205,8 @@ class ScimUserEndpointDocs extends EndpointDocs { fieldWithPath("zoneId").type(STRING).description(userZoneIdDescription), fieldWithPath("passwordLastModified").type(STRING).description(passwordLastModifiedDescription), fieldWithPath("externalId").type(STRING).description(externalIdDescription), + fieldWithPath("aliasId").optional().type(STRING).description(aliasIdDescription), + fieldWithPath("aliasZid").optional().type(STRING).description(aliasZidDescription), fieldWithPath("meta").type(OBJECT).description(metaDesc), fieldWithPath("meta.version").type(NUMBER).description(metaVersionDesc), fieldWithPath("meta.lastModified").type(STRING).description(metaLastModifiedDesc), @@ -215,6 +233,8 @@ class ScimUserEndpointDocs extends EndpointDocs { fieldWithPath("zoneId").ignored().type(STRING).description(userZoneIdDescription), fieldWithPath("passwordLastModified").ignored().type(STRING).description(passwordLastModifiedDescription), fieldWithPath("externalId").optional(null).type(STRING).description(externalIdDescription), + fieldWithPath("aliasId").optional(null).type(STRING).description(aliasIdUpdateRequestDescription), + fieldWithPath("aliasZid").optional(null).type(STRING).description(aliasZidUpdateRequestDescription), fieldWithPath("meta.*").ignored().type(OBJECT).description("SCIM object meta data not read.") ); @@ -249,6 +269,8 @@ class ScimUserEndpointDocs extends EndpointDocs { fieldWithPath("lastLogonTime").optional(null).type(NUMBER).description(userLastLogonTimeDescription), fieldWithPath("previousLogonTime").optional(null).type(NUMBER).description(userLastLogonTimeDescription), fieldWithPath("externalId").type(STRING).description(externalIdDescription), + fieldWithPath("aliasId").optional().type(STRING).description(aliasIdDescription), + fieldWithPath("aliasZid").optional().type(STRING).description(aliasZidDescription), fieldWithPath("meta").type(OBJECT).description(metaDesc), fieldWithPath("meta.version").type(NUMBER).description(metaVersionDesc), fieldWithPath("meta.lastModified").type(STRING).description(metaLastModifiedDesc), @@ -275,6 +297,8 @@ class ScimUserEndpointDocs extends EndpointDocs { fieldWithPath("zoneId").ignored().type(STRING).description(userZoneIdDescription), fieldWithPath("passwordLastModified").ignored().type(STRING).description(passwordLastModifiedDescription), fieldWithPath("externalId").optional(null).type(STRING).description(externalIdDescription), + fieldWithPath("aliasId").optional(null).type(STRING).description(aliasIdPatchRequestDescription), + fieldWithPath("aliasZid").optional(null).type(STRING).description(aliasZidPatchRequestDescription), fieldWithPath("meta.*").ignored().type(OBJECT).description("SCIM object meta data not read."), fieldWithPath("meta.attributes").optional(null).type(ARRAY).description(metaAttributesDesc) ); diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimUserEndpointsAliasMockMvcTests.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimUserEndpointsAliasMockMvcTests.java new file mode 100644 index 00000000000..0d3816a1023 --- /dev/null +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimUserEndpointsAliasMockMvcTests.java @@ -0,0 +1,1619 @@ +package org.cloudfoundry.identity.uaa.scim.endpoints; + +import com.fasterxml.jackson.core.type.TypeReference; +import org.apache.commons.lang.StringUtils; +import org.cloudfoundry.identity.uaa.DefaultTestContext; +import org.cloudfoundry.identity.uaa.alias.AliasMockMvcTestBase; +import org.cloudfoundry.identity.uaa.mock.util.MockMvcUtils; +import org.cloudfoundry.identity.uaa.provider.IdentityProvider; +import org.cloudfoundry.identity.uaa.provider.IdentityProviderAliasHandler; +import org.cloudfoundry.identity.uaa.provider.IdentityProviderEndpoints; +import org.cloudfoundry.identity.uaa.resources.SearchResults; +import org.cloudfoundry.identity.uaa.scim.ScimMeta; +import org.cloudfoundry.identity.uaa.scim.ScimUser; +import org.cloudfoundry.identity.uaa.scim.ScimUserAliasHandler; +import org.cloudfoundry.identity.uaa.scim.jdbc.JdbcScimUserProvisioning; +import org.cloudfoundry.identity.uaa.util.JsonUtils; +import org.cloudfoundry.identity.uaa.zone.IdentityZone; +import org.cloudfoundry.identity.uaa.zone.IdentityZoneConfiguration; +import org.cloudfoundry.identity.uaa.zone.IdentityZoneSwitchingFilter; +import org.cloudfoundry.identity.uaa.zone.UserConfig; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static java.util.Objects.requireNonNull; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; +import static org.cloudfoundry.identity.uaa.constants.OriginKeys.OIDC10; +import static org.cloudfoundry.identity.uaa.scim.ScimUser.Group.Type.DIRECT; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@DefaultTestContext +public class ScimUserEndpointsAliasMockMvcTests extends AliasMockMvcTestBase { + private IdentityProviderAliasHandler idpEntityAliasHandler; + private IdentityProviderEndpoints identityProviderEndpoints; + private ScimUserAliasHandler scimUserAliasHandler; + private ScimUserEndpoints scimUserEndpoints; + + @BeforeEach + void setUp() throws Exception { + setUpTokensAndCustomZone(); + + idpEntityAliasHandler = requireNonNull(webApplicationContext.getBean(IdentityProviderAliasHandler.class)); + identityProviderEndpoints = requireNonNull(webApplicationContext.getBean(IdentityProviderEndpoints.class)); + scimUserAliasHandler = requireNonNull(webApplicationContext.getBean(ScimUserAliasHandler.class)); + scimUserEndpoints = requireNonNull(webApplicationContext.getBean(ScimUserEndpoints.class)); + } + + @Nested + class Read { + @Nested + class AliasFeatureDisabled { + @BeforeEach + void setUp() { + arrangeAliasFeatureEnabled(false); + } + + @AfterEach + void tearDown() { + arrangeAliasFeatureEnabled(true); + } + + @Test + void shouldStillReturnAliasPropertiesOfUsersWithAliasCreatedBeforehand_UaaToCustomZone() throws Throwable { + shouldStillReturnAliasPropertiesOfUsersWithAliasCreatedBeforehand(uaaZone, customZone); + } + + @Test + void shouldStillReturnAliasPropertiesOfUsersWithAliasCreatedBeforehand_CustomToUaaZone() throws Throwable { + shouldStillReturnAliasPropertiesOfUsersWithAliasCreatedBeforehand(customZone, uaaZone); + } + + private void shouldStillReturnAliasPropertiesOfUsersWithAliasCreatedBeforehand( + final IdentityZone zone1, + final IdentityZone zone2 + ) throws Throwable { + final IdentityProvider idpWithAlias = executeWithTemporarilyEnabledAliasFeature( + false, + () -> createIdpWithAlias(zone1, zone2) + ); + + // create a user with an alias in zone 1 + final ScimUser scimUser = buildScimUser( + idpWithAlias.getOriginKey(), + zone1.getId(), + null, + zone2.getId() + ); + final ScimUser createdUserWithAlias = executeWithTemporarilyEnabledAliasFeature( + false, + () -> createScimUser(zone1, scimUser) + ); + assertThat(createdUserWithAlias.getAliasId()).isNotBlank(); + assertThat(createdUserWithAlias.getAliasZid()).isNotBlank().isEqualTo(zone2.getId()); + + // read all users in zone 1 and search for created user + final List allUsersInZone1 = readRecentlyCreatedUsersInZone(zone1); + final Optional createdUserOpt = allUsersInZone1.stream() + .filter(user -> user.getUserName().equals(createdUserWithAlias.getUserName())) + .findFirst(); + assertThat(createdUserOpt).isPresent(); + + // check if the user has non-empty alias properties + final ScimUser createdUser = createdUserOpt.get(); + assertThat(createdUser).isEqualTo(createdUserWithAlias); + assertThat(createdUser.getAliasId()).isNotBlank().isEqualTo(createdUserWithAlias.getAliasId()); + assertThat(createdUser.getAliasZid()).isNotBlank().isEqualTo(zone2.getId()); + } + } + } + + @Nested + class Create { + abstract class CreateBase { + protected final boolean aliasFeatureEnabled; + + protected CreateBase(final boolean aliasFeatureEnabled) { + this.aliasFeatureEnabled = aliasFeatureEnabled; + } + + @BeforeEach + void setUp() { + arrangeAliasFeatureEnabled(aliasFeatureEnabled); + } + + @AfterEach + void tearDown() { + arrangeAliasFeatureEnabled(true); + } + + @Test + final void shouldAccept_AliasPropertiesNotSet_UaaToCustomZone() throws Throwable { + shouldAccept_AliasPropertiesNotSet(uaaZone, customZone); + } + + @Test + final void shouldAccept_AliasPropertiesNotSet_CustomToUaaZone() throws Throwable { + shouldAccept_AliasPropertiesNotSet(customZone, uaaZone); + } + + private void shouldAccept_AliasPropertiesNotSet( + final IdentityZone zone1, + final IdentityZone zone2 + ) throws Throwable { + final IdentityProvider idpWithAlias = executeWithTemporarilyEnabledAliasFeature( + aliasFeatureEnabled, + () -> createIdpWithAlias(zone1, zone2) + ); + + // create a user with the IdP as its origin but without an alias itself + final ScimUser scimUserWithoutAlias = buildScimUser( + idpWithAlias.getOriginKey(), + zone1.getId(), + null, + null + ); + final ScimUser createdScimUserWithoutAlias = createScimUser(zone1, scimUserWithoutAlias); + assertThat(createdScimUserWithoutAlias.getAliasId()).isBlank(); + assertThat(createdScimUserWithoutAlias.getAliasZid()).isBlank(); + } + + @Test + final void shouldReject_AliasIdSet_UaaToCustomZone() throws Throwable { + shouldReject_AliasIdSet(uaaZone, customZone); + } + + @Test + final void shouldReject_AliasIdSet_CustomToUaaZone() throws Throwable { + shouldReject_AliasIdSet(customZone, uaaZone); + } + + private void shouldReject_AliasIdSet(final IdentityZone zone1, final IdentityZone zone2) throws Throwable { + final IdentityProvider idpWithAlias = executeWithTemporarilyEnabledAliasFeature( + aliasFeatureEnabled, + () -> createIdpWithAlias(zone1, zone2) + ); + + final ScimUser scimUser = buildScimUser( + idpWithAlias.getOriginKey(), + zone1.getId(), + UUID.randomUUID().toString(), + null + ); + shouldRejectCreation(zone1, scimUser, HttpStatus.BAD_REQUEST); + } + } + + @Nested + class AliasFeatureEnabled extends CreateBase { + protected AliasFeatureEnabled() { + super(true); + } + + @Test + void shouldAccept_ShouldCreateAliasUser_UaaToCustomZone() throws Throwable { + shouldAccept_ShouldCreateAliasUser(uaaZone, customZone); + } + + @Test + void shouldAccept_ShouldCreateAliasUser_CustomToUaaZone() throws Throwable { + shouldAccept_ShouldCreateAliasUser(customZone, uaaZone); + } + + private void shouldAccept_ShouldCreateAliasUser( + final IdentityZone zone1, + final IdentityZone zone2 + ) throws Throwable { + final IdentityProvider idpWithAlias = executeWithTemporarilyEnabledAliasFeature( + aliasFeatureEnabled, + () -> createIdpWithAlias(zone1, zone2) + ); + + final ScimUser scimUser = buildScimUser( + idpWithAlias.getOriginKey(), + zone1.getId(), + null, + zone2.getId() + ); + final ScimUser createdScimUser = createScimUser(zone1, scimUser); + + // find alias user + final List usersZone2 = readRecentlyCreatedUsersInZone(zone2); + final Optional aliasUserOpt = usersZone2.stream() + .filter(user -> user.getId().equals(createdScimUser.getAliasId())) + .findFirst(); + assertThat(aliasUserOpt).isPresent(); + + assertIsCorrectAliasPair(createdScimUser, aliasUserOpt.get(), zone2); + } + + @Test + void shouldReject_UserAlreadyExistsInOtherZone_UaaToCustomZone() throws Throwable { + shouldReject_UserAlreadyExistsInOtherZone(uaaZone, customZone); + } + + @Test + void shouldReject_UserAlreadyExistsInOtherZone_CustomToUaaZone() throws Throwable { + shouldReject_UserAlreadyExistsInOtherZone(customZone, uaaZone); + } + + private void shouldReject_UserAlreadyExistsInOtherZone( + final IdentityZone zone1, + final IdentityZone zone2 + ) throws Throwable { + final IdentityProvider idpWithAlias = executeWithTemporarilyEnabledAliasFeature( + aliasFeatureEnabled, + () -> createIdpWithAlias(zone1, zone2) + ); + + // create user in zone 2 + final ScimUser existingScimUser = buildScimUser( + idpWithAlias.getOriginKey(), + zone2.getId(), + null, + null + ); + final ScimUser createdScimUser = createScimUser(zone2, existingScimUser); + + // try to create similar user in zone 1 with aliasZid set to zone 2 + final ScimUser scimUser = buildScimUser( + idpWithAlias.getOriginKey(), + zone1.getId(), + null, + zone2.getId() + ); + assertThat(createdScimUser.getUserName()).isEqualTo(scimUser.getUserName()); + shouldRejectCreation(zone1, scimUser, HttpStatus.CONFLICT); + } + + @Test + void shouldReject_IdzIdAndAliasZidAreEqual_UaaZone() throws Throwable { + shouldReject_IdzIdAndAliasZidAreEqual(uaaZone, customZone); + } + + @Test + void shouldReject_IdzIdAndAliasZidAreEqual_CustomZone() throws Throwable { + shouldReject_IdzIdAndAliasZidAreEqual(customZone, uaaZone); + } + + private void shouldReject_IdzIdAndAliasZidAreEqual( + final IdentityZone zone1, + final IdentityZone zone2 + ) throws Throwable { + final IdentityProvider idpWithAlias = executeWithTemporarilyEnabledAliasFeature( + aliasFeatureEnabled, + () -> createIdpWithAlias(zone1, zone2) + ); + + final ScimUser scimUser = buildScimUser( + idpWithAlias.getOriginKey(), + zone1.getId(), + null, + zone1.getId() + ); + shouldRejectCreation(zone1, scimUser, HttpStatus.BAD_REQUEST); + } + + @Test + void shouldReject_NeitherIdzIdNorAliasZidIsUaa() throws Throwable { + final IdentityZone otherCustomZone = MockMvcUtils.createZoneUsingWebRequest(mockMvc, identityToken); + + final IdentityProvider idpWithAlias = executeWithTemporarilyEnabledAliasFeature( + aliasFeatureEnabled, + // similar to users, IdPs also cannot be created from one custom IdZ to another custom one + () -> createIdpWithAlias(customZone, uaaZone) + ); + + final ScimUser scimUser = buildScimUser( + idpWithAlias.getOriginKey(), + customZone.getId(), + null, + otherCustomZone.getId() + ); + shouldRejectCreation(customZone, scimUser, HttpStatus.BAD_REQUEST); + } + + @Test + void shouldReject_IdzReferencedInAliasZidDoesNotExist() throws Throwable { + final IdentityZone zone1 = uaaZone; + final IdentityProvider idpWithAlias = executeWithTemporarilyEnabledAliasFeature( + aliasFeatureEnabled, + () -> createIdpWithAlias(zone1, customZone) + ); + + final ScimUser scimUser = buildScimUser( + idpWithAlias.getOriginKey(), + zone1.getId(), + null, + UUID.randomUUID().toString() // no zone with this ID will exist + ); + shouldRejectCreation(zone1, scimUser, HttpStatus.BAD_REQUEST); + } + + @Test + void shouldReject_OriginIdpHasNoAlias_UaaToCustomZone() throws Throwable { + shouldReject_OriginIdpHasNoAlias(uaaZone, customZone); + } + + @Test + void shouldReject_OriginIdpHasNoAlias_CustomToUaaZone() throws Throwable { + shouldReject_OriginIdpHasNoAlias(customZone, uaaZone); + } + + private void shouldReject_OriginIdpHasNoAlias( + final IdentityZone zone1, + final IdentityZone zone2 + ) throws Throwable { + final IdentityProvider idpWithoutAlias = buildIdpWithAliasProperties( + zone1.getId(), + null, + null, + RANDOM_STRING_GENERATOR.generate(), + OIDC10 + ); + final IdentityProvider createdIdpWithoutAlias = executeWithTemporarilyEnabledAliasFeature( + aliasFeatureEnabled, + () -> createIdp(zone1, idpWithoutAlias) + ); + + final ScimUser userWithAlias = buildScimUser( + createdIdpWithoutAlias.getOriginKey(), + zone1.getId(), + null, + zone2.getId() + ); + shouldRejectCreation(zone1, userWithAlias, HttpStatus.BAD_REQUEST); + } + + @Test + void shouldReject_OriginIdpHasAliasInDifferentZone_UaaToCustomZone() throws Throwable { + shouldReject_OriginIdpHasAliasInDifferentZone(uaaZone, customZone); + } + + @Test + void shouldReject_OriginIdpHasAliasInDifferentZone_CustomToUaaZone() throws Throwable { + shouldReject_OriginIdpHasAliasInDifferentZone(customZone, uaaZone); + } + + private void shouldReject_OriginIdpHasAliasInDifferentZone( + final IdentityZone zone1, + final IdentityZone zone2 + ) throws Throwable { + final IdentityProvider createdIdpWithAlias = executeWithTemporarilyEnabledAliasFeature( + aliasFeatureEnabled, + () -> createIdpWithAlias(zone1, zone2) + ); + + final IdentityZone otherCustomZone = MockMvcUtils.createZoneUsingWebRequest(mockMvc, identityToken); + + final ScimUser userWithAlias = buildScimUser( + createdIdpWithAlias.getOriginKey(), + zone1.getId(), + null, + otherCustomZone.getId() + ); + shouldRejectCreation(zone1, userWithAlias, HttpStatus.BAD_REQUEST); + } + } + + @Nested + class AliasFeatureDisabled extends CreateBase { + protected AliasFeatureDisabled() { + super(false); + } + + @Test + void shouldReject_OnlyAliasZidSet_UaaToCustomZone() throws Throwable { + shouldReject_OnlyAliasZidSet(uaaZone, customZone); + } + + @Test + void shouldReject_OnlyAliasZidSet_CustomToUaaZone() throws Throwable { + shouldReject_OnlyAliasZidSet(customZone, uaaZone); + } + + private void shouldReject_OnlyAliasZidSet( + final IdentityZone zone1, + final IdentityZone zone2 + ) throws Throwable { + final IdentityProvider idpWithAlias = executeWithTemporarilyEnabledAliasFeature( + aliasFeatureEnabled, + () -> createIdpWithAlias(zone1, zone2) + ); + + final ScimUser scimUser = buildScimUser( + idpWithAlias.getOriginKey(), + zone1.getId(), + null, + zone2.getId() + ); + shouldRejectCreation(zone1, scimUser, HttpStatus.BAD_REQUEST); + } + } + + private void shouldRejectCreation( + final IdentityZone zone, + final ScimUser scimUser, + final HttpStatus expectedStatus + ) throws Exception { + final MvcResult result = createScimUserAndReturnResult(zone, scimUser); + assertThat(result.getResponse().getStatus()).isEqualTo(expectedStatus.value()); + } + } + + @Nested + class Update { + abstract class UpdateBase { + protected final boolean aliasFeatureEnabled; + + protected UpdateBase(final boolean aliasFeatureEnabled) { + this.aliasFeatureEnabled = aliasFeatureEnabled; + } + + @BeforeEach + void setUp() { + arrangeAliasFeatureEnabled(aliasFeatureEnabled); + } + + @AfterEach + void tearDown() { + arrangeAliasFeatureEnabled(true); + } + + @ParameterizedTest + @EnumSource(value = HttpMethod.class, names = {"PUT", "PATCH"}) + final void shouldReject_NoExistingAlias_AliasIdSet_UaaToCustomZone(final HttpMethod method) throws Throwable { + shouldReject_NoExistingAlias_AliasIdSet(method, uaaZone, customZone); + } + + @ParameterizedTest + @EnumSource(value = HttpMethod.class, names = {"PUT", "PATCH"}) + final void shouldReject_NoExistingAlias_AliasIdSet_CustomToUaaZone(final HttpMethod method) throws Throwable { + shouldReject_NoExistingAlias_AliasIdSet(method, customZone, uaaZone); + } + + private void shouldReject_NoExistingAlias_AliasIdSet( + final HttpMethod method, + final IdentityZone zone1, + final IdentityZone zone2 + ) throws Throwable { + final IdentityProvider idpWithAlias = executeWithTemporarilyEnabledAliasFeature( + aliasFeatureEnabled, + () -> createIdpWithAlias(zone1, zone2) + ); + + final ScimUser scimUser = buildScimUser( + idpWithAlias.getOriginKey(), + zone1.getId(), + null, + null + ); + final ScimUser createdScimUser = createScimUser(zone1, scimUser); + + createdScimUser.setAliasId(UUID.randomUUID().toString()); + shouldRejectUpdate(method, zone1, createdScimUser, HttpStatus.BAD_REQUEST); + } + } + + @Nested + class AliasFeatureEnabled extends UpdateBase { + public AliasFeatureEnabled() { + super(true); + } + + @Nested + class ExistingAlias { + @ParameterizedTest + @EnumSource(value = HttpMethod.class, names = {"PUT", "PATCH"}) + void shouldAccept_AliasPropsNotChanged_ShouldPropagateChangesToAliasUser_UaaToCustomZone(final HttpMethod method) throws Throwable { + shouldAccept_AliasPropsNotChanged_ShouldPropagateChangesToAliasUser(method, uaaZone, customZone); + } + + @ParameterizedTest + @EnumSource(value = HttpMethod.class, names = {"PUT", "PATCH"}) + void shouldAccept_AliasPropsNotChanged_ShouldPropagateChangesToAliasUser_CustomToUaaZone(final HttpMethod method) throws Throwable { + shouldAccept_AliasPropsNotChanged_ShouldPropagateChangesToAliasUser(method, customZone, uaaZone); + } + + private void shouldAccept_AliasPropsNotChanged_ShouldPropagateChangesToAliasUser( + final HttpMethod method, + final IdentityZone zone1, + final IdentityZone zone2 + ) throws Throwable { + final ScimUser createdScimUser = executeWithTemporarilyEnabledAliasFeature( + aliasFeatureEnabled, + () -> createIdpAndUserWithAlias(zone1, zone2) + ); + + final String newUserName = "some-new-username"; + createdScimUser.setUserName(newUserName); + final ScimUser updatedScimUser = updateUser(method, zone1, createdScimUser); + assertThat(updatedScimUser.getUserName()).isEqualTo(newUserName); + + final Optional aliasUserOpt = readUserFromZoneIfExists( + createdScimUser.getAliasId(), + zone2.getId() + ); + assertThat(aliasUserOpt).isPresent(); + + assertIsCorrectAliasPair(updatedScimUser, aliasUserOpt.get(), zone2); + } + + @ParameterizedTest + @EnumSource(value = HttpMethod.class, names = {"PUT", "PATCH"}) + void shouldAccept_ShouldFixDanglingReference_UaaToCustomZone(final HttpMethod method) throws Throwable { + shouldAccept_ShouldFixDanglingReference(method, uaaZone, customZone); + } + + @ParameterizedTest + @EnumSource(value = HttpMethod.class, names = {"PUT", "PATCH"}) + void shouldAccept_ShouldFixDanglingReference_CustomToUaaZone(final HttpMethod method) throws Throwable { + shouldAccept_ShouldFixDanglingReference(method, customZone, uaaZone); + } + + private void shouldAccept_ShouldFixDanglingReference( + final HttpMethod method, + final IdentityZone zone1, + final IdentityZone zone2 + ) throws Throwable { + final ScimUser createdScimUser = executeWithTemporarilyEnabledAliasFeature( + aliasFeatureEnabled, + () -> createIdpAndUserWithAlias(zone1, zone2) + ); + final String initialAliasId = createdScimUser.getAliasId(); + assertThat(initialAliasId).isNotBlank(); + + // create dangling reference by deleting alias user + deleteUserViaDb(initialAliasId, zone2.getId()); + + // update the original user + final String newUserName = "some-new-username"; + createdScimUser.setUserName(newUserName); + final ScimUser updatedScimUser = updateUser(method, zone1, createdScimUser); + assertThat(updatedScimUser.getUserName()).isEqualTo(newUserName); + + // the dangling reference should be fixed + final String newAliasId = updatedScimUser.getAliasId(); + assertThat(newAliasId).isNotBlank().isNotEqualTo(initialAliasId); + final Optional newAliasUserOpt = readUserFromZoneIfExists( + newAliasId, + zone2.getId() + ); + assertThat(newAliasUserOpt).isPresent(); + assertIsCorrectAliasPair(updatedScimUser, newAliasUserOpt.get(), zone2); + } + + @ParameterizedTest + @EnumSource(value = HttpMethod.class, names = {"PUT", "PATCH"}) + void shouldReject_DanglingReferenceButConflictingUserAlreadyExistsInAliasZone_UaaToCustomZone(final HttpMethod method) throws Throwable { + shouldReject_DanglingReferenceButConflictingUserAlreadyExistsInAliasZone(method, uaaZone, customZone); + } + + @ParameterizedTest + @EnumSource(value = HttpMethod.class, names = {"PUT", "PATCH"}) + void shouldReject_DanglingReferenceButConflictingUserAlreadyExistsInAliasZone_CustomToUaaZone(final HttpMethod method) throws Throwable { + shouldReject_DanglingReferenceButConflictingUserAlreadyExistsInAliasZone(method, customZone, uaaZone); + } + + private void shouldReject_DanglingReferenceButConflictingUserAlreadyExistsInAliasZone( + final HttpMethod method, + final IdentityZone zone1, + final IdentityZone zone2 + ) throws Throwable { + final ScimUser createdScimUser = executeWithTemporarilyEnabledAliasFeature( + aliasFeatureEnabled, + () -> createIdpAndUserWithAlias(zone1, zone2) + ); + + // create dangling reference by deleting the alias user directly via DB + final String aliasId = createdScimUser.getAliasId(); + assertThat(aliasId).isNotBlank(); + deleteUserViaDb(aliasId, zone2.getId()); + + // create a new user without alias in the alias zone that has the same username as the original user + final ScimUser conflictingUser = buildScimUser( + createdScimUser.getOrigin(), + zone2.getId(), + null, + null + ); + createScimUser(zone2, conflictingUser); + + // update the original user - fixing the dangling ref. not possible since conflicting user exists + createdScimUser.setNickName("some-new-nickname"); + shouldRejectUpdate(method, zone1, createdScimUser, HttpStatus.CONFLICT); + } + + @ParameterizedTest + @EnumSource(value = HttpMethod.class, names = {"PUT", "PATCH"}) + void shouldReject_AliasIdSetInExistingButAliasZidNot_UaaToCustomZone(final HttpMethod method) throws Throwable { + shouldReject_AliasIdSetInExistingButAliasZidNot(method, uaaZone, customZone); + } + + @ParameterizedTest + @EnumSource(value = HttpMethod.class, names = {"PUT", "PATCH"}) + void shouldReject_AliasIdSetInExistingButAliasZidNot_CustomToUaaZone(final HttpMethod method) throws Throwable { + shouldReject_AliasIdSetInExistingButAliasZidNot(method, customZone, uaaZone); + } + + private void shouldReject_AliasIdSetInExistingButAliasZidNot( + final HttpMethod method, + final IdentityZone zone1, + final IdentityZone zone2 + ) throws Throwable { + final ScimUser createdScimUser = executeWithTemporarilyEnabledAliasFeature( + aliasFeatureEnabled, + () -> createIdpAndUserWithAlias(zone1, zone2) + ); + final String initialAliasId = createdScimUser.getAliasId(); + assertThat(initialAliasId).isNotBlank(); + + // remove 'aliasId' directly in DB + createdScimUser.setAliasId(null); + updateUserViaDb(createdScimUser, zone1.getId()); + + // otherwise valid update should now fail + createdScimUser.setAliasId(initialAliasId); + createdScimUser.setUserName("some-new-username"); + shouldRejectUpdate(method, zone1, createdScimUser, HttpStatus.INTERNAL_SERVER_ERROR); + } + + @ParameterizedTest + @EnumSource(value = HttpMethod.class, names = {"PUT", "PATCH"}) + void shouldReject_AliasPropertiesChanged_UaaToCustomZone(final HttpMethod method) throws Throwable { + shouldReject_AliasPropertiesChanged(method, uaaZone, customZone); + } + + @ParameterizedTest + @EnumSource(value = HttpMethod.class, names = {"PUT", "PATCH"}) + void shouldReject_AliasPropertiesChanged_CustomToUaaZone(final HttpMethod method) throws Throwable { + shouldReject_AliasPropertiesChanged(method, customZone, uaaZone); + } + + private void shouldReject_AliasPropertiesChanged( + final HttpMethod method, + final IdentityZone zone1, + final IdentityZone zone2 + ) throws Throwable { + final ScimUser createdScimUser = executeWithTemporarilyEnabledAliasFeature( + aliasFeatureEnabled, + () -> createIdpAndUserWithAlias(zone1, zone2) + ); + + createdScimUser.setAliasZid(StringUtils.EMPTY); + shouldRejectUpdate(method, zone1, createdScimUser, HttpStatus.BAD_REQUEST); + } + + @ParameterizedTest + @EnumSource(value = HttpMethod.class, names = {"PUT", "PATCH"}) + void shouldReject_DanglingReferenceAndZoneNotExisting_UaaToCustomZone(final HttpMethod method) throws Throwable { + shouldReject_DanglingReferenceAndZoneNotExisting(method, uaaZone, customZone); + } + + @ParameterizedTest + @EnumSource(value = HttpMethod.class, names = {"PUT", "PATCH"}) + void shouldReject_DanglingReferenceAndZoneNotExisting_CustomToUaaZone(final HttpMethod method) throws Throwable { + shouldReject_DanglingReferenceAndZoneNotExisting(method, customZone, uaaZone); + } + + private void shouldReject_DanglingReferenceAndZoneNotExisting( + final HttpMethod method, + final IdentityZone zone1, + final IdentityZone zone2 + ) throws Throwable { + final ScimUser createdScimUser = executeWithTemporarilyEnabledAliasFeature( + aliasFeatureEnabled, + () -> createIdpAndUserWithAlias(zone1, zone2) + ); + + // create a dangling reference by changing the alias zone to a non-existing one + createdScimUser.setAliasZid(UUID.randomUUID().toString()); + final ScimUser userWithDanglingRef = updateUserViaDb(createdScimUser, zone1.getId()); + + // updating the user should fail - the dangling reference cannot be fixed + userWithDanglingRef.setUserName("some-new-username"); + shouldRejectUpdate(method, zone1, userWithDanglingRef, HttpStatus.UNPROCESSABLE_ENTITY); + } + } + + @Nested + class NoExistingAlias { + @ParameterizedTest + @EnumSource(value = HttpMethod.class, names = {"PUT", "PATCH"}) + void shouldAccept_ShouldCreateNewAliasIfOnlyAliasZidSet_UaaToCustomZone(final HttpMethod method) throws Throwable { + shouldAccept_ShouldCreateNewAliasIfOnlyAliasZidSet(method, uaaZone, customZone); + } + + @ParameterizedTest + @EnumSource(value = HttpMethod.class, names = {"PUT", "PATCH"}) + void shouldAccept_ShouldCreateNewAliasIfOnlyAliasZidSet_CustomToUaaZone(final HttpMethod method) throws Throwable { + shouldAccept_ShouldCreateNewAliasIfOnlyAliasZidSet(method, customZone, uaaZone); + } + + private void shouldAccept_ShouldCreateNewAliasIfOnlyAliasZidSet( + final HttpMethod method, + final IdentityZone zone1, + final IdentityZone zone2 + ) throws Throwable { + final ScimUser createdScimUser = executeWithTemporarilyEnabledAliasFeature( + aliasFeatureEnabled, + () -> createIdpWithAliasAndUserWithoutAlias(zone1, zone2) + ); + + createdScimUser.setAliasZid(zone2.getId()); + final ScimUser updatedScimUser = updateUser(method, zone1, createdScimUser); + assertThat(updatedScimUser.getAliasId()).isNotBlank(); + assertThat(updatedScimUser.getAliasZid()).isNotBlank().isEqualTo(zone2.getId()); + + final Optional aliasUserOpt = readUserFromZoneIfExists( + updatedScimUser.getAliasId(), + updatedScimUser.getAliasZid() + ); + assertThat(aliasUserOpt).isPresent(); + final ScimUser aliasUser = aliasUserOpt.get(); + assertIsCorrectAliasPair(updatedScimUser, aliasUser, zone2); + } + + @ParameterizedTest + @EnumSource(value = HttpMethod.class, names = {"PUT", "PATCH"}) + void shouldReject_ConflictingUserAlreadyExistsInAliasZone_UaaToCustomZone(final HttpMethod method) throws Throwable { + shouldReject_ConflictingUserAlreadyExistsInAliasZone(method, uaaZone, customZone); + } + + @ParameterizedTest + @EnumSource(value = HttpMethod.class, names = {"PUT", "PATCH"}) + void shouldReject_ConflictingUserAlreadyExistsInAliasZone_CustomToUaaZone(final HttpMethod method) throws Throwable { + shouldReject_ConflictingUserAlreadyExistsInAliasZone(method, customZone, uaaZone); + } + + private void shouldReject_ConflictingUserAlreadyExistsInAliasZone( + final HttpMethod method, + final IdentityZone zone1, + final IdentityZone zone2 + ) throws Throwable { + // create an IdP in zone 1 with an alias in zone 2 and a user without alias + final ScimUser createdUserWithoutAlias = executeWithTemporarilyEnabledAliasFeature( + aliasFeatureEnabled, + () -> createIdpWithAliasAndUserWithoutAlias(zone1, zone2) + ); + + // create a user with the same username in zone 2 + final ScimUser conflictingUser = buildScimUser( + createdUserWithoutAlias.getOrigin(), + zone2.getId(), + null, + null + ); + createScimUser(zone2, conflictingUser); + + // try to update the user with aliasZid set to zone 2 - should fail + createdUserWithoutAlias.setAliasZid(zone2.getId()); + shouldRejectUpdate(method, zone1, createdUserWithoutAlias, HttpStatus.CONFLICT); + } + + @ParameterizedTest + @EnumSource(value = HttpMethod.class, names = {"PUT", "PATCH"}) + void shouldReject_OriginIdpHasNoAlias_UaaToCustomZone(final HttpMethod method) throws Exception { + shouldReject_OriginIdpHasNoAlias(method, uaaZone, customZone); + } + + @ParameterizedTest + @EnumSource(value = HttpMethod.class, names = {"PUT", "PATCH"}) + void shouldReject_OriginIdpHasNoAlias_CustomToUaaZone(final HttpMethod method) throws Exception { + shouldReject_OriginIdpHasNoAlias(method, customZone, uaaZone); + } + + private void shouldReject_OriginIdpHasNoAlias( + final HttpMethod method, + final IdentityZone zone1, + final IdentityZone zone2 + ) throws Exception { + // create an IdP without alias + final IdentityProvider idpWithoutAlias = buildIdpWithAliasProperties( + zone1.getId(), + null, + null, + RANDOM_STRING_GENERATOR.generate(), + OIDC10 + ); + final IdentityProvider createdIdpWithoutAlias = createIdp(zone1, idpWithoutAlias); + + // create a user without an alias + final ScimUser userWithoutAlias = buildScimUser( + createdIdpWithoutAlias.getOriginKey(), + zone1.getId(), + null, + null + ); + final ScimUser createdUserWithoutAlias = createScimUser(zone1, userWithoutAlias); + + // try to update user with aliasZid set to zone 2 - should fail + createdUserWithoutAlias.setAliasZid(zone2.getId()); + shouldRejectUpdate(method, zone1, createdUserWithoutAlias, HttpStatus.BAD_REQUEST); + } + + @ParameterizedTest + @EnumSource(value = HttpMethod.class, names = {"PUT", "PATCH"}) + void shouldReject_OriginIdpHasAliasToDifferentZone(final HttpMethod method) throws Throwable { + final IdentityZone zone1 = uaaZone; + final IdentityZone zone2 = customZone; + final IdentityZone zone3 = MockMvcUtils.createZoneUsingWebRequest(mockMvc, identityToken); + + // create IdP in zone 1 with alias in zone 2 and user without alias + final ScimUser createdScimUser = executeWithTemporarilyEnabledAliasFeature( + aliasFeatureEnabled, + () -> createIdpWithAliasAndUserWithoutAlias(zone1, zone2) + ); + + // try to update user with aliasZid set to a different custom zone (zone 3) - should fail + createdScimUser.setAliasZid(zone3.getId()); + shouldRejectUpdate(method, zone1, createdScimUser, HttpStatus.BAD_REQUEST); + } + + @ParameterizedTest + @EnumSource(value = HttpMethod.class, names = {"PUT", "PATCH"}) + void shouldReject_ReferencedAliasZoneDesNotExist(final HttpMethod method) throws Throwable { + final IdentityZone zone1 = uaaZone; + + final ScimUser createdScimUser = executeWithTemporarilyEnabledAliasFeature( + aliasFeatureEnabled, + () -> createIdpWithAliasAndUserWithoutAlias(zone1, customZone) + ); + + // update user with aliasZid set to a non-existing - should fail + createdScimUser.setAliasZid(UUID.randomUUID().toString()); + shouldRejectUpdate(method, zone1, createdScimUser, HttpStatus.BAD_REQUEST); + } + + @ParameterizedTest + @EnumSource(value = HttpMethod.class, names = {"PUT", "PATCH"}) + void shouldReject_AliasZidSetToSameZone_UaaToCustomZone(final HttpMethod method) throws Throwable { + shouldReject_AliasZidSetToSameZone(method, uaaZone, customZone); + } + + @ParameterizedTest + @EnumSource(value = HttpMethod.class, names = {"PUT", "PATCH"}) + void shouldReject_AliasZidSetToSameZone_CustomToUaaZone(final HttpMethod method) throws Throwable { + shouldReject_AliasZidSetToSameZone(method, customZone, uaaZone); + } + + private void shouldReject_AliasZidSetToSameZone( + final HttpMethod method, + final IdentityZone zone1, + final IdentityZone zone2 + ) throws Throwable { + final ScimUser createdScimUser = executeWithTemporarilyEnabledAliasFeature( + aliasFeatureEnabled, + () -> createIdpWithAliasAndUserWithoutAlias(zone1, zone2) + ); + + // update user with alias in same zone - should fail + createdScimUser.setAliasZid(zone1.getId()); + shouldRejectUpdate(method, zone1, createdScimUser, HttpStatus.BAD_REQUEST); + } + + @ParameterizedTest + @EnumSource(value = HttpMethod.class, names = {"PUT", "PATCH"}) + void shouldReject_AliasZidSetToDifferentCustomZone(final HttpMethod method) throws Throwable { + final IdentityZone zone1 = customZone; + final IdentityZone zone2 = uaaZone; + + final ScimUser createdScimUser = executeWithTemporarilyEnabledAliasFeature( + aliasFeatureEnabled, + () -> createIdpWithAliasAndUserWithoutAlias(zone1, zone2) + ); + + // update user with aliasZid set to a different custom zone - should fail + final IdentityZone otherCustomZone = MockMvcUtils.createZoneUsingWebRequest(mockMvc, identityToken); + createdScimUser.setAliasZid(otherCustomZone.getId()); + shouldRejectUpdate(method, zone1, createdScimUser, HttpStatus.BAD_REQUEST); + } + } + } + + @Nested + class AliasFeatureDisabled extends UpdateBase { + public AliasFeatureDisabled() { + super(false); + } + + @Nested + class ExistingAlias { + @ParameterizedTest + @EnumSource(value = HttpMethod.class, names = {"PUT", "PATCH"}) + void shouldReject_OnlyAliasPropsSetToNull_UaaToCustomZone(final HttpMethod method) throws Throwable { + shouldReject_OnlyAliasPropsSetToNull(method, uaaZone, customZone); + } + + @ParameterizedTest + @EnumSource(value = HttpMethod.class, names = {"PUT", "PATCH"}) + void shouldReject_OnlyAliasPropsSetToNull_CustomToUaaZone(final HttpMethod method) throws Throwable { + shouldReject_OnlyAliasPropsSetToNull(method, customZone, uaaZone); + } + + private void shouldReject_OnlyAliasPropsSetToNull( + final HttpMethod method, + final IdentityZone zone1, + final IdentityZone zone2 + ) throws Throwable { + final ScimUser createdScimUser = executeWithTemporarilyEnabledAliasFeature( + aliasFeatureEnabled, + () -> createIdpAndUserWithAlias(zone1, zone2) + ); + + createdScimUser.setAliasId(StringUtils.EMPTY); + createdScimUser.setAliasZid(StringUtils.EMPTY); + + shouldRejectUpdate(method, zone1, createdScimUser, HttpStatus.BAD_REQUEST); + } + + @ParameterizedTest + @EnumSource(value = HttpMethod.class, names = {"PUT", "PATCH"}) + void shouldReject_AliasPropsSetToNullAndOtherPropsChanged_UaaToCustomZone(final HttpMethod method) throws Throwable { + shouldReject_AliasPropsSetToNullAndOtherPropsChanged(method, uaaZone, customZone); + } + + @ParameterizedTest + @EnumSource(value = HttpMethod.class, names = {"PUT", "PATCH"}) + void shouldReject_AliasPropsSetToNullAndOtherPropsChanged_CustomToUaaZone(final HttpMethod method) throws Throwable { + shouldReject_AliasPropsSetToNullAndOtherPropsChanged(method, customZone, uaaZone); + } + + private void shouldReject_AliasPropsSetToNullAndOtherPropsChanged( + final HttpMethod method, + final IdentityZone zone1, + final IdentityZone zone2 + ) throws Throwable { + final ScimUser createdScimUser = executeWithTemporarilyEnabledAliasFeature( + aliasFeatureEnabled, + () -> createIdpAndUserWithAlias(zone1, zone2) + ); + + createdScimUser.setAliasId(StringUtils.EMPTY); + createdScimUser.setAliasZid(StringUtils.EMPTY); + final String newGivenName = "some-new-given-name"; + createdScimUser.setName(new ScimUser.Name(newGivenName, createdScimUser.getFamilyName())); + + shouldRejectUpdate(method, zone1, createdScimUser, HttpStatus.BAD_REQUEST); + } + + @ParameterizedTest + @EnumSource(value = HttpMethod.class, names = {"PUT", "PATCH"}) + void shouldReject_EvenIfAliasIdMissingInExistingUser_UaaToCustomZone(final HttpMethod method) throws Throwable { + shouldReject_EvenIfAliasIdMissingInExistingUser(method, uaaZone, customZone); + } + + @ParameterizedTest + @EnumSource(value = HttpMethod.class, names = {"PUT", "PATCH"}) + void shouldReject_EvenIfAliasIdMissingInExistingUser_CustomToUaaZone(final HttpMethod method) throws Throwable { + shouldReject_EvenIfAliasIdMissingInExistingUser(method, customZone, uaaZone); + } + + private void shouldReject_EvenIfAliasIdMissingInExistingUser( + final HttpMethod method, + final IdentityZone zone1, + final IdentityZone zone2 + ) throws Throwable { + final ScimUser createdScimUser = executeWithTemporarilyEnabledAliasFeature( + aliasFeatureEnabled, + () -> createIdpAndUserWithAlias(zone1, zone2) + ); + + // remove aliasId field directly in DB + createdScimUser.setAliasId(null); + final ScimUser scimUserWithIncompleteRef = updateUserViaDb(createdScimUser, zone1.getId()); + + scimUserWithIncompleteRef.setAliasZid(StringUtils.EMPTY); + shouldRejectUpdate(method, zone1, scimUserWithIncompleteRef, HttpStatus.BAD_REQUEST); + } + + @ParameterizedTest + @EnumSource(value = HttpMethod.class, names = {"PUT", "PATCH"}) + void shouldReject_DanglingRef_UaaToCustomZone(final HttpMethod method) throws Throwable { + shouldReject_DanglingRef(method, uaaZone, customZone); + } + + @ParameterizedTest + @EnumSource(value = HttpMethod.class, names = {"PUT", "PATCH"}) + void shouldReject_DanglingRef_CustomToUaaZone(final HttpMethod method) throws Throwable { + shouldReject_DanglingRef(method, customZone, uaaZone); + } + + private void shouldReject_DanglingRef( + final HttpMethod method, + final IdentityZone zone1, + final IdentityZone zone2 + ) throws Throwable { + final ScimUser createdScimUser = executeWithTemporarilyEnabledAliasFeature( + aliasFeatureEnabled, + () -> createIdpAndUserWithAlias(zone1, zone2) + ); + final String aliasId = createdScimUser.getAliasId(); + assertThat(aliasId).isNotBlank(); + final String aliasZid = createdScimUser.getAliasZid(); + assertThat(aliasZid).isNotBlank(); + + // create dangling reference by deleting alias + deleteUserViaDb(aliasId, aliasZid); + + // should reject update even if there is a dangling reference + shouldRejectUpdate(method, zone1, createdScimUser, HttpStatus.BAD_REQUEST); + } + + @ParameterizedTest + @EnumSource(value = HttpMethod.class, names = {"PUT", "PATCH"}) + void shouldReject_OnlyNonAliasPropertiesChanged_UaaToCustomZone(final HttpMethod method) throws Throwable { + shouldReject_OnlyNonAliasPropertiesChanged(method, uaaZone, customZone); + } + + @ParameterizedTest + @EnumSource(value = HttpMethod.class, names = {"PUT", "PATCH"}) + void shouldReject_OnlyNonAliasPropertiesChanged_CustomToUaaZone(final HttpMethod method) throws Throwable { + shouldReject_OnlyNonAliasPropertiesChanged(method, customZone, uaaZone); + } + + private void shouldReject_OnlyNonAliasPropertiesChanged( + final HttpMethod method, + final IdentityZone zone1, + final IdentityZone zone2 + ) throws Throwable { + final ScimUser createdScimUser = executeWithTemporarilyEnabledAliasFeature( + aliasFeatureEnabled, + () -> createIdpAndUserWithAlias(zone1, zone2) + ); + + createdScimUser.setNickName("some-new-nickname"); + shouldRejectUpdate(method, zone1, createdScimUser, HttpStatus.BAD_REQUEST); + } + + @ParameterizedTest + @EnumSource(value = HttpMethod.class, names = {"PUT", "PATCH"}) + void shouldReject_OnlyAliasIdSetToNull_UaaToCustomZone(final HttpMethod method) throws Throwable { + shouldReject_OnlyAliasIdSetToNull(method, uaaZone, customZone); + } + + @ParameterizedTest + @EnumSource(value = HttpMethod.class, names = {"PUT", "PATCH"}) + void shouldReject_OnlyAliasIdSetToNull_CustomToUaaZone(final HttpMethod method) throws Throwable { + shouldReject_OnlyAliasIdSetToNull(method, customZone, uaaZone); + } + + private void shouldReject_OnlyAliasIdSetToNull( + final HttpMethod method, + final IdentityZone zone1, + final IdentityZone zone2 + ) throws Throwable { + final ScimUser createdScimUser = executeWithTemporarilyEnabledAliasFeature( + aliasFeatureEnabled, + () -> createIdpAndUserWithAlias(zone1, zone2) + ); + + createdScimUser.setAliasId(null); + shouldRejectUpdate(method, zone1, createdScimUser, HttpStatus.BAD_REQUEST); + } + + @ParameterizedTest + @EnumSource(value = HttpMethod.class, names = {"PUT", "PATCH"}) + void shouldReject_OnlyAliasZidSetToNull_UaaToCustomZone(final HttpMethod method) throws Throwable { + shouldReject_OnlyAliasZidSetToNull(method, uaaZone, customZone); + } + + @ParameterizedTest + @EnumSource(value = HttpMethod.class, names = {"PUT", "PATCH"}) + void shouldReject_OnlyAliasZidSetToNull_CustomToUaaZone(final HttpMethod method) throws Throwable { + shouldReject_OnlyAliasZidSetToNull(method, customZone, uaaZone); + } + + private void shouldReject_OnlyAliasZidSetToNull( + final HttpMethod method, + final IdentityZone zone1, + final IdentityZone zone2 + ) throws Throwable { + final ScimUser createdScimUser = executeWithTemporarilyEnabledAliasFeature( + aliasFeatureEnabled, + () -> createIdpAndUserWithAlias(zone1, zone2) + ); + + createdScimUser.setAliasZid(null); + shouldRejectUpdate(method, zone1, createdScimUser, HttpStatus.BAD_REQUEST); + } + } + + @Nested + class NoExistingAlias { + @ParameterizedTest + @EnumSource(value = HttpMethod.class, names = {"PUT", "PATCH"}) + void shouldReject_OnlyAliasZidSet_UaaToCustomZone(final HttpMethod method) throws Throwable { + shouldReject_OnlyAliasZidSet(method, uaaZone, customZone); + } + + @ParameterizedTest + @EnumSource(value = HttpMethod.class, names = {"PUT", "PATCH"}) + void shouldReject_OnlyAliasZidSet_CustomToUaaZone(final HttpMethod method) throws Throwable { + shouldReject_OnlyAliasZidSet(method, customZone, uaaZone); + } + + private void shouldReject_OnlyAliasZidSet( + final HttpMethod method, + final IdentityZone zone1, + final IdentityZone zone2 + ) throws Throwable { + final ScimUser createdScimUser = executeWithTemporarilyEnabledAliasFeature( + aliasFeatureEnabled, + () -> createIdpWithAliasAndUserWithoutAlias(zone1, zone2) + ); + + createdScimUser.setAliasZid(zone2.getId()); + shouldRejectUpdate(method, zone1, createdScimUser, HttpStatus.BAD_REQUEST); + } + } + } + + private ScimUser updateUser( + final HttpMethod method, + final IdentityZone zone, + final ScimUser scimUser + ) throws Exception { + final MvcResult result = updateUserAndReturnResult(method, zone, scimUser); + assertThat(result).isNotNull(); + final MockHttpServletResponse response = result.getResponse(); + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value()); + return JsonUtils.readValue( + response.getContentAsString(), + ScimUser.class + ); + } + + private MvcResult updateUserAndReturnResult( + final HttpMethod method, + final IdentityZone zone, + final ScimUser scimUser + ) throws Exception { + final String userId = scimUser.getId(); + assertThat(userId).isNotBlank(); + + MockHttpServletRequestBuilder updateRequestBuilder; + switch (method) { + case PUT: + updateRequestBuilder = put("/Users/" + userId); + break; + case PATCH: + updateRequestBuilder = patch("/Users/" + userId); + break; + default: + fail("Encountered invalid HTTP method: " + method); + return null; + } + updateRequestBuilder = updateRequestBuilder + .header("Authorization", "Bearer " + getAccessTokenForZone(zone.getId())) + .header(IdentityZoneSwitchingFilter.HEADER, zone.getSubdomain()) + .header("If-Match", scimUser.getVersion()) + .contentType(APPLICATION_JSON) + .content(JsonUtils.writeValueAsString(scimUser)); + + return mockMvc.perform(updateRequestBuilder).andReturn(); + } + + private void shouldRejectUpdate( + final HttpMethod method, + final IdentityZone zone, + final ScimUser scimUser, + final HttpStatus expectedStatusCode + ) throws Exception { + final MvcResult result = updateUserAndReturnResult(method, zone, scimUser); + assertThat(result).isNotNull(); + assertThat(result.getResponse().getStatus()).isEqualTo(expectedStatusCode.value()); + } + } + + @Nested + class Delete { + abstract class DeleteBase { + protected final boolean aliasFeatureEnabled; + + protected DeleteBase(final boolean aliasFeatureEnabled) { + this.aliasFeatureEnabled = aliasFeatureEnabled; + } + + @BeforeEach + void setUp() { + arrangeAliasFeatureEnabled(aliasFeatureEnabled); + } + + @AfterEach + void tearDown() { + arrangeAliasFeatureEnabled(true); + } + } + + @Nested + class AliasFeatureEnabled extends DeleteBase { + protected AliasFeatureEnabled() { + super(true); + } + + @Test + void shouldAlsoDeleteAliasUser_UaaToCustomZone() throws Throwable { + shouldAlsoDeleteAliasUser(uaaZone, customZone); + } + + @Test + void shouldAlsoDeleteAliasUser_CustomToUaaZone() throws Throwable { + shouldAlsoDeleteAliasUser(customZone, uaaZone); + } + + private void shouldAlsoDeleteAliasUser( + final IdentityZone zone1, + final IdentityZone zone2 + ) throws Throwable { + final IdentityProvider idpWithAlias = executeWithTemporarilyEnabledAliasFeature( + aliasFeatureEnabled, + () -> createIdpWithAlias(zone1, zone2) + ); + + final ScimUser userWithAlias = buildScimUser( + idpWithAlias.getOriginKey(), + zone1.getId(), + null, + zone2.getId() + ); + final ScimUser createdUserWithAlias = executeWithTemporarilyEnabledAliasFeature( + aliasFeatureEnabled, + () -> createScimUser(zone1, userWithAlias) + ); + + // should remove both the user and its alias + shouldSuccessfullyDeleteUser(createdUserWithAlias, zone1); + assertUserDoesNotExist(createdUserWithAlias.getAliasId(), zone2.getId()); + } + + @Test + void shouldIgnoreDanglingReference_UaaToCustomZone() throws Throwable { + shouldIgnoreDanglingReference(uaaZone, customZone); + } + + @Test + void shouldIgnoreDanglingReference_CustomToUaaZone() throws Throwable { + shouldIgnoreDanglingReference(customZone, uaaZone); + } + + private void shouldIgnoreDanglingReference( + final IdentityZone zone1, + final IdentityZone zone2 + ) throws Throwable { + final IdentityProvider idpWithAlias = executeWithTemporarilyEnabledAliasFeature( + aliasFeatureEnabled, + () -> createIdpWithAlias(zone1, zone2) + ); + + final ScimUser userWithAlias = buildScimUser( + idpWithAlias.getOriginKey(), + zone1.getId(), + null, + zone2.getId() + ); + final ScimUser createdUserWithAlias = executeWithTemporarilyEnabledAliasFeature( + aliasFeatureEnabled, + () -> createScimUser(zone1, userWithAlias) + ); + assertThat(createdUserWithAlias.getAliasId()).isNotBlank(); + assertThat(createdUserWithAlias.getAliasZid()).isNotBlank(); + + // create dangling reference by removing alias user directly in DB + deleteUserViaDb(createdUserWithAlias.getAliasId(), createdUserWithAlias.getAliasZid()); + + // deletion should still work + shouldSuccessfullyDeleteUser(createdUserWithAlias, zone1); + } + } + + @Nested + class AliasFeatureDisabled extends DeleteBase { + protected AliasFeatureDisabled() { + super(false); + } + + @Test + void shouldRejectDeletion_WhenAliasUserExists_UaaToCustomZone() throws Throwable { + shouldRejectDeletion_WhenAliasUserExists(uaaZone, customZone); + } + + @Test + void shouldRejectDeletion_WhenAliasUserExists_CustomToUaaZone() throws Throwable { + shouldRejectDeletion_WhenAliasUserExists(customZone, uaaZone); + } + + private void shouldRejectDeletion_WhenAliasUserExists( + final IdentityZone zone1, + final IdentityZone zone2 + ) throws Throwable { + final IdentityProvider idpWithAlias = executeWithTemporarilyEnabledAliasFeature( + aliasFeatureEnabled, + () -> createIdpWithAlias(zone1, zone2) + ); + + final ScimUser userWithAlias = buildScimUser( + idpWithAlias.getOriginKey(), + zone1.getId(), + null, + zone2.getId() + ); + final ScimUser createdUserWithAlias = executeWithTemporarilyEnabledAliasFeature( + aliasFeatureEnabled, + () -> createScimUser(zone1, userWithAlias) + ); + + shouldRejectDeletion(createdUserWithAlias.getId(), zone1, HttpStatus.BAD_REQUEST); + + // both users should still be present + assertThat(readUserFromZoneIfExists( + createdUserWithAlias.getId(), + createdUserWithAlias.getZoneId() + )).isPresent(); + assertThat(readUserFromZoneIfExists( + createdUserWithAlias.getAliasId(), + createdUserWithAlias.getAliasZid() + )).isPresent(); + } + + private void shouldRejectDeletion( + final String userId, + final IdentityZone zone, + final HttpStatus expectedStatusCode + ) throws Exception { + assertThat(expectedStatusCode.isError()).isTrue(); + final MvcResult result = deleteScimUserAndReturnResult(userId, zone); + assertThat(result).isNotNull(); + final MockHttpServletResponse response = result.getResponse(); + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(expectedStatusCode.value()); + } + } + + private void shouldSuccessfullyDeleteUser(final ScimUser user, final IdentityZone zone) throws Exception { + final MvcResult result = deleteScimUserAndReturnResult(user.getId(), zone); + assertThat(result.getResponse().getStatus()).isEqualTo(HttpStatus.OK.value()); + } + + private MvcResult deleteScimUserAndReturnResult(final String userId, final IdentityZone zone) throws Exception { + final MockHttpServletRequestBuilder deleteRequestBuilder = delete("/Users/" + userId) + .header("Authorization", "Bearer " + getAccessTokenForZone(zone.getId())) + .header(IdentityZoneSwitchingFilter.HEADER, zone.getSubdomain()); + return mockMvc.perform(deleteRequestBuilder).andReturn(); + } + + private void assertUserDoesNotExist(final String id, final String zoneId) throws Exception { + final Optional user = readUserFromZoneIfExists(id, zoneId); + assertThat(user).isNotPresent(); + } + } + + private ScimUser createIdpAndUserWithAlias( + final IdentityZone zone1, + final IdentityZone zone2 + ) throws Throwable { + final IdentityProvider idpWithAlias = createIdpWithAlias(zone1, zone2); + + final ScimUser scimUser = buildScimUser( + idpWithAlias.getOriginKey(), + zone1.getId(), + null, + zone2.getId() + ); + return createScimUser(zone1, scimUser); + } + + private static void assertIsCorrectAliasPair( + final ScimUser originalUser, + final ScimUser aliasUser, + final IdentityZone aliasZone + ) { + assertThat(originalUser).isNotNull(); + assertThat(aliasUser).isNotNull(); + + // 'id' field will differ + assertThat(originalUser.getId()).isNotBlank().isNotEqualTo(aliasUser.getId()); + assertThat(aliasUser.getId()).isNotBlank().isNotEqualTo(originalUser.getId()); + + // 'aliasId' and 'aliasZid' should point to the other entity, respectively + assertThat(originalUser.getAliasId()).isNotBlank().isEqualTo(aliasUser.getId()); + assertThat(aliasUser.getAliasId()).isNotBlank().isEqualTo(originalUser.getId()); + assertThat(originalUser.getAliasZid()).isNotBlank().isEqualTo(aliasUser.getZoneId()); + assertThat(aliasUser.getAliasZid()).isNotBlank().isEqualTo(originalUser.getZoneId()); + + // the other properties should be equal + + assertThat(originalUser.getUserName()).isEqualTo(aliasUser.getUserName()); + assertThat(originalUser.getName()).isNotNull(); + assertThat(aliasUser.getName()).isNotNull(); + assertThat(originalUser.getName().getGivenName()).isNotBlank().isEqualTo(aliasUser.getName().getGivenName()); + assertThat(originalUser.getName().getFamilyName()).isNotBlank().isEqualTo(aliasUser.getName().getFamilyName()); + + assertThat(originalUser.getOrigin()).isEqualTo(aliasUser.getOrigin()); + assertThat(originalUser.getExternalId()).isEqualTo(aliasUser.getExternalId()); + + assertThat(originalUser.getEmails()).isEqualTo(aliasUser.getEmails()); + assertThat(originalUser.getPrimaryEmail()).isEqualTo(aliasUser.getPrimaryEmail()); + assertThat(originalUser.getPhoneNumbers()).isEqualTo(aliasUser.getPhoneNumbers()); + + assertThat(originalUser.isActive()).isEqualTo(aliasUser.isActive()); + assertThat(originalUser.isVerified()).isEqualTo(aliasUser.isVerified()); + + // in the API response, the password and salt must be null for both the original and the alias user + assertThat(originalUser.getPassword()).isNull(); + assertThat(originalUser.getSalt()).isNull(); + assertThat(aliasUser.getPassword()).isNull(); + assertThat(aliasUser.getSalt()).isNull(); + + // approvals must be empty for the alias user + assertThat(aliasUser.getApprovals()).isEmpty(); + + // apart from the default groups of the alias zone, the alias user must have no groups + final Optional> defaultGroupNamesAliasZoneOpt = Optional.ofNullable(aliasZone.getConfig()) + .map(IdentityZoneConfiguration::getUserConfig) + .map(UserConfig::getDefaultGroups); + assertThat(defaultGroupNamesAliasZoneOpt).isPresent(); + final List defaultGroupNamesAliasZone = defaultGroupNamesAliasZoneOpt.get(); + assertThat(aliasUser.getGroups()).isNotNull().hasSize(defaultGroupNamesAliasZone.size()); + final List directGroupNamesAliasUser = aliasUser.getGroups().stream() + .filter(group -> group.getType() == DIRECT) + .map(ScimUser.Group::getDisplay) + .toList(); + assertThat(directGroupNamesAliasUser).hasSameElementsAs(defaultGroupNamesAliasZone); + + final ScimMeta originalUserMeta = originalUser.getMeta(); + assertThat(originalUserMeta).isNotNull(); + final ScimMeta aliasUserMeta = aliasUser.getMeta(); + assertThat(aliasUserMeta).isNotNull(); + // 'created', 'lastModified' and 'version' are expected to be different + assertThat(originalUserMeta.getAttributes()).isEqualTo(aliasUserMeta.getAttributes()); + + assertThat(originalUser.getSchemas()).isEqualTo(aliasUser.getSchemas()); + } + + private static ScimUser buildScimUser( + final String origin, + final String zoneId, + final String aliasId, + final String aliasZid + ) { + final ScimUser scimUser = new ScimUser(); + scimUser.setOrigin(origin); + scimUser.setAliasId(aliasId); + scimUser.setAliasZid(aliasZid); + scimUser.setZoneId(zoneId); + + scimUser.setUserName("john.doe"); + scimUser.setName(new ScimUser.Name("John", "Doe")); + scimUser.setPrimaryEmail("john.doe@example.com"); + scimUser.setPassword("some-password"); + + return scimUser; + } + + /** + * Create an SCIM user in the given zone and assert that the operation is successful. + */ + private ScimUser createScimUser(final IdentityZone zone, final ScimUser scimUser) throws Exception { + final MvcResult createResult = createScimUserAndReturnResult(zone, scimUser); + assertThat(createResult.getResponse().getStatus()).isEqualTo(HttpStatus.CREATED.value()); + final ScimUser createdScimUser = JsonUtils.readValue( + createResult.getResponse().getContentAsString(), + ScimUser.class + ); + assertThat(createdScimUser).isNotNull(); + assertThat(createdScimUser.getPassword()).isBlank(); // the password should never be returned + return createdScimUser; + } + + private MvcResult createScimUserAndReturnResult( + final IdentityZone zone, + final ScimUser scimUser + ) throws Exception { + final MockHttpServletRequestBuilder createRequestBuilder = post("/Users") + .header("Authorization", "Bearer " + getAccessTokenForZone(zone.getId())) + .header(IdentityZoneSwitchingFilter.HEADER, zone.getSubdomain()) + .contentType(APPLICATION_JSON) + .content(JsonUtils.writeValueAsString(scimUser)); + return mockMvc.perform(createRequestBuilder).andReturn(); + } + + private ScimUser createIdpWithAliasAndUserWithoutAlias( + final IdentityZone zone1, + final IdentityZone zone2 + ) throws Throwable { + final IdentityProvider idpWithAlias = createIdpWithAlias(zone1, zone2); + + // create user without alias + final ScimUser scimUser = buildScimUser( + idpWithAlias.getOriginKey(), + zone1.getId(), + null, + null + ); + return createScimUser(zone1, scimUser); + } + + private List readRecentlyCreatedUsersInZone(final IdentityZone zone) throws Exception { + final MockHttpServletRequestBuilder getRequestBuilder = get("/Users") + .header(IdentityZoneSwitchingFilter.SUBDOMAIN_HEADER, zone.getSubdomain()) + .header("Authorization", "Bearer " + getAccessTokenForZone(zone.getId())) + // return most recent users in first page to avoid querying for further pages + .param("sortBy", "created") + .param("sortOrder", "descending"); + final MvcResult getResult = mockMvc.perform(getRequestBuilder).andExpect(status().isOk()).andReturn(); + final SearchResults searchResults = JsonUtils.readValue( + getResult.getResponse().getContentAsString(), + new TypeReference<>() { + } + ); + assertThat(searchResults).isNotNull(); + return searchResults.getResources(); + } + + private Optional readUserFromZoneIfExists(final String id, final String zoneId) throws Exception { + final MockHttpServletRequestBuilder getRequestBuilder = get("/Users/" + id) + .header(IdentityZoneSwitchingFilter.HEADER, zoneId) + .header("Authorization", "Bearer " + getAccessTokenForZone(zoneId)); + final MvcResult getResult = mockMvc.perform(getRequestBuilder).andReturn(); + final int responseStatus = getResult.getResponse().getStatus(); + assertThat(responseStatus).isIn(404, 200); + + switch (responseStatus) { + case 404: + return Optional.empty(); + case 200: + final ScimUser responseBody = JsonUtils.readValue( + getResult.getResponse().getContentAsString(), + ScimUser.class + ); + return Optional.ofNullable(responseBody); + default: + // should not happen + return Optional.empty(); + } + } + + private ScimUser updateUserViaDb(final ScimUser user, final String zoneId) { + final JdbcScimUserProvisioning scimUserProvisioning = webApplicationContext + .getBean(JdbcScimUserProvisioning.class); + assertThat(user.getId()).isNotBlank(); + return scimUserProvisioning.update(user.getId(), user, zoneId); + } + + private void deleteUserViaDb(final String id, final String zoneId) { + final JdbcScimUserProvisioning scimUserProvisioning = webApplicationContext + .getBean(JdbcScimUserProvisioning.class); + final int rowsDeleted = scimUserProvisioning.deleteByUser(id, zoneId); + assertThat(rowsDeleted).isEqualTo(1); + } + + @Override + protected void arrangeAliasFeatureEnabled(final boolean enabled) { + ReflectionTestUtils.setField(idpEntityAliasHandler, "aliasEntitiesEnabled", enabled); + ReflectionTestUtils.setField(identityProviderEndpoints, "aliasEntitiesEnabled", enabled); + ReflectionTestUtils.setField(scimUserAliasHandler, "aliasEntitiesEnabled", enabled); + ReflectionTestUtils.setField(scimUserEndpoints, "aliasEntitiesEnabled", enabled); + } +} diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimUserEndpointsMockMvcTests.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimUserEndpointsMockMvcTests.java index e239933f89a..eb5037a2006 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimUserEndpointsMockMvcTests.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimUserEndpointsMockMvcTests.java @@ -60,7 +60,6 @@ import org.cloudfoundry.identity.uaa.scim.ScimUser; import org.cloudfoundry.identity.uaa.scim.ScimUserProvisioning; import org.cloudfoundry.identity.uaa.scim.exception.UserAlreadyVerifiedException; -import org.cloudfoundry.identity.uaa.scim.jdbc.JdbcScimUserProvisioning; import org.cloudfoundry.identity.uaa.scim.test.JsonObjectMatcherUtils; import org.cloudfoundry.identity.uaa.test.TestClient; import org.cloudfoundry.identity.uaa.test.ZoneSeeder; @@ -398,121 +397,6 @@ void create_user_without_email() throws Exception { .put("error", "invalid_scim_resource")))); } - /** - * For now, the properties "aliasId" and "aliasZid" should be ignored at the API level. In particular, if provided, - * their value should NOT be persisted in the DB. In a future version of UAA, the proper handling of these values - * is added. Then, these tests will be removed again. - */ - @Nested - class ShouldIgnoreAliasProperties { - @Test - void createUser_ShouldIgnoreAliasProperties() throws Exception { - final ScimUser user = new ScimUser(null, "a_user", "Joel", "D'sa"); - user.setPassword("password"); - user.setPrimaryEmail("john.doe@example.com"); - user.setAliasId(UUID.randomUUID().toString()); - user.setAliasZid(UUID.randomUUID().toString()); - - final MvcResult result = createUserAndReturnResult(user, scimReadWriteToken, null, null) - .andReturn(); - final MockHttpServletResponse response = result.getResponse(); - Assertions.assertThat(response).isNotNull(); - - // the response should not contain JSON fields for the alias properties - final String responseBodyAsString = response.getContentAsString(); - Assertions.assertThat(responseBodyAsString).isNotBlank().doesNotContain("alias"); - - // both alias properties should be empty - final ScimUser createdUser = JsonUtils.readValue(responseBodyAsString, ScimUser.class); - Assertions.assertThat(createdUser.getAliasId()).isBlank(); - Assertions.assertThat(createdUser.getAliasZid()).isBlank(); - - // the alias properties should also be empty in the DB - final String userId = createdUser.getId(); - Assertions.assertThat(userId).isNotBlank(); - - assertUserHasEmptyAliasPropsInDb(userId, IdentityZone.getUaaZoneId()); - } - - @Test - void updateUser_ShouldIgnoreAliasProperties() throws Exception { - final String email = "john.doe.%s@example.com".formatted(RandomStringUtils.randomAlphabetic(5)); - - // create user with empty alias properties - final ScimUser user = new ScimUser(null, email, "Joel", "D'sa"); - user.setPassword("password"); - user.setPrimaryEmail(email); - user.setAliasId(null); - user.setAliasZid(null); - final ScimUser createdUser = createUser(user, scimReadWriteToken, null); - - // update the user: set alias properties - createdUser.setAliasId(UUID.randomUUID().toString()); - createdUser.setAliasZid(UUID.randomUUID().toString()); - final MvcResult updateResult = updateUserAndReturnResult(scimReadWriteToken, createdUser); - final MockHttpServletResponse updateResponse = updateResult.getResponse(); - Assertions.assertThat(updateResponse).isNotNull(); - - // the response should not contain JSON fields for the alias properties - final String responseBodyAsString = updateResponse.getContentAsString(); - Assertions.assertThat(responseBodyAsString).isNotBlank().doesNotContain("alias"); - - // both alias properties should be empty - final ScimUser updatedUser = JsonUtils.readValue(responseBodyAsString, ScimUser.class); - Assertions.assertThat(updatedUser.getAliasId()).isBlank(); - Assertions.assertThat(updatedUser.getAliasZid()).isBlank(); - - // the alias properties should also be empty in the DB - final String userId = updatedUser.getId(); - Assertions.assertThat(userId).isNotBlank(); - - assertUserHasEmptyAliasPropsInDb(userId, IdentityZone.getUaaZoneId()); - } - - @Test - void patchUser_ShouldIgnoreAliasProperties() throws Exception { - final String email = "john.doe.%s@example.com".formatted(RandomStringUtils.randomAlphabetic(5)); - - // create user with empty alias properties - final ScimUser user = new ScimUser(null, email, "Joel", "D'sa"); - user.setPassword("password"); - user.setPrimaryEmail(email); - user.setAliasId(null); - user.setAliasZid(null); - final ScimUser createdUser = createUser(user, scimReadWriteToken, null); - - // update the user: set alias properties - createdUser.setAliasId(UUID.randomUUID().toString()); - createdUser.setAliasZid(UUID.randomUUID().toString()); - final MvcResult updateResult = patchUser(createdUser, scimReadWriteToken, createdUser.getVersion()).andReturn(); - final MockHttpServletResponse updateResponse = updateResult.getResponse(); - Assertions.assertThat(updateResponse).isNotNull(); - - // the response should not contain JSON fields for the alias properties - final String responseBodyAsString = updateResponse.getContentAsString(); - Assertions.assertThat(responseBodyAsString).isNotBlank().doesNotContain("alias"); - - // both alias properties should be empty - final ScimUser updatedUser = JsonUtils.readValue(responseBodyAsString, ScimUser.class); - Assertions.assertThat(updatedUser.getAliasId()).isBlank(); - Assertions.assertThat(updatedUser.getAliasZid()).isBlank(); - - // the alias properties should also be empty in the DB - final String userId = updatedUser.getId(); - Assertions.assertThat(userId).isNotBlank(); - - assertUserHasEmptyAliasPropsInDb(userId, IdentityZone.getUaaZoneId()); - } - - private void assertUserHasEmptyAliasPropsInDb(final String userId, final String zoneId) { - final JdbcScimUserProvisioning scimUserProvisioning = webApplicationContext.getBean(JdbcScimUserProvisioning.class); - final ScimUser userFromDb = scimUserProvisioning.retrieve(userId, zoneId); - Assertions.assertThat(userFromDb).isNotNull(); - Assertions.assertThat(userFromDb.getAliasId()).isBlank(); - Assertions.assertThat(userFromDb.getAliasZid()).isBlank(); - } - } - @Test void create_user_then_update_without_email() throws Exception { ScimUser user = setUpScimUser(); diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimUserEndpointsTests.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimUserEndpointsTests.java index 66721055e0e..aee57bb05ab 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimUserEndpointsTests.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimUserEndpointsTests.java @@ -7,6 +7,7 @@ import org.cloudfoundry.identity.uaa.approval.Approval; import org.cloudfoundry.identity.uaa.approval.ApprovalStore; import org.cloudfoundry.identity.uaa.constants.OriginKeys; +import org.cloudfoundry.identity.uaa.oauth.common.util.RandomValueStringGenerator; import org.cloudfoundry.identity.uaa.provider.IdentityProvider; import org.cloudfoundry.identity.uaa.provider.JdbcIdentityProviderProvisioning; import org.cloudfoundry.identity.uaa.provider.LdapIdentityProviderDefinition; @@ -22,6 +23,7 @@ import org.cloudfoundry.identity.uaa.scim.ScimGroupMembershipManager; import org.cloudfoundry.identity.uaa.scim.ScimMeta; import org.cloudfoundry.identity.uaa.scim.ScimUser; +import org.cloudfoundry.identity.uaa.scim.ScimUserAliasHandler; import org.cloudfoundry.identity.uaa.scim.ScimUserProvisioning; import org.cloudfoundry.identity.uaa.scim.exception.InvalidPasswordException; import org.cloudfoundry.identity.uaa.scim.exception.InvalidScimResourceException; @@ -36,6 +38,7 @@ import org.cloudfoundry.identity.uaa.test.ZoneSeederExtension; import org.cloudfoundry.identity.uaa.web.ConvertingExceptionView; import org.cloudfoundry.identity.uaa.zone.IdentityZone; +import org.cloudfoundry.identity.uaa.zone.IdentityZoneProvisioning; import org.cloudfoundry.identity.uaa.zone.beans.IdentityZoneManager; import org.cloudfoundry.identity.uaa.zone.beans.IdentityZoneManagerImpl; import org.junit.jupiter.api.BeforeEach; @@ -56,9 +59,9 @@ import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.security.crypto.password.PasswordEncoder; -import org.cloudfoundry.identity.uaa.oauth.common.util.RandomValueStringGenerator; import org.springframework.test.context.TestPropertySource; import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.support.TransactionTemplate; import org.springframework.web.servlet.View; import java.util.ArrayList; @@ -70,6 +73,7 @@ import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.UUID; @@ -138,6 +142,15 @@ class ScimUserEndpointsTests { @Autowired private IdentityZoneManager identityZoneManager; + private ScimUserAliasHandler scimUserAliasHandler; + + @Autowired + private TransactionTemplate transactionTemplate; + + @Autowired + @Qualifier("identityZoneProvisioning") + private IdentityZoneProvisioning identityZoneProvisioning; + private ScimUser joel; private ScimUser dale; @@ -197,6 +210,12 @@ void setUpAfterSeeding(final IdentityZone identityZone) { spiedScimGroupMembershipManager = spy(scimGroupMembershipManager); + scimUserAliasHandler = mock(ScimUserAliasHandler.class); + when(scimUserAliasHandler.aliasPropertiesAreValid(any(), any())).thenReturn(true); + when(scimUserAliasHandler.ensureConsistencyOfAliasEntity(any(), any())) + .then(invocationOnMock -> invocationOnMock.getArgument(0)); + when(scimUserAliasHandler.retrieveAliasEntity(any())).thenReturn(Optional.empty()); + scimUserEndpoints = new ScimUserEndpoints( new IdentityZoneManagerImpl(), new IsSelfCheck(null), @@ -208,7 +227,11 @@ void setUpAfterSeeding(final IdentityZone identityZone) { null, mockApprovalStore, spiedScimGroupMembershipManager, - 5); + scimUserAliasHandler, + transactionTemplate, + false, + 5 + ); } @Test @@ -698,7 +721,7 @@ void findUsersApprovalsNotSyncedIfNotIncluded() { void whenSettingAnInvalidUserMaxCount_ScimUsersEndpointShouldThrowAnException() { assertThrowsWithMessageThat( IllegalArgumentException.class, - () -> new ScimUserEndpoints(null, null, null, null, null, null, null, null, null, null, 0), + () -> new ScimUserEndpoints(null, null, null, null, null, null, null, null, null, null, null, null, false, 0), containsString("Invalid \"userMaxCount\" value (got 0). Should be positive number.")); } @@ -706,7 +729,7 @@ void whenSettingAnInvalidUserMaxCount_ScimUsersEndpointShouldThrowAnException() void whenSettingANegativeValueUserMaxCount_ScimUsersEndpointShouldThrowAnException() { assertThrowsWithMessageThat( IllegalArgumentException.class, - () -> new ScimUserEndpoints(null, null, null, null, null, null, null, null, null, null, -1), + () -> new ScimUserEndpoints(null, null, null, null, null, null, null, null, null, null, null, null, false, -1), containsString("Invalid \"userMaxCount\" value (got -1). Should be positive number.")); }