diff --git a/server/src/main/java/access/api/UserRoleController.java b/server/src/main/java/access/api/UserRoleController.java index 9e762dd6..4904fb12 100644 --- a/server/src/main/java/access/api/UserRoleController.java +++ b/server/src/main/java/access/api/UserRoleController.java @@ -6,7 +6,11 @@ import access.logging.AccessLogger; import access.logging.Event; import access.model.*; +import access.provision.ProvisioningService; +import access.provision.graph.GraphResponse; +import access.provision.scim.OperationType; import access.repository.RoleRepository; +import access.repository.UserRepository; import access.repository.UserRoleRepository; import access.security.UserPermissions; import io.swagger.v3.oas.annotations.Parameter; @@ -17,12 +21,13 @@ import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import java.time.Instant; -import java.util.List; -import java.util.Map; +import java.time.temporal.ChronoUnit; +import java.util.*; import static access.SwaggerOpenIdConfig.API_TOKENS_SCHEME_NAME; import static access.SwaggerOpenIdConfig.OPEN_ID_SCHEME_NAME; @@ -39,11 +44,19 @@ public class UserRoleController { private final UserRoleRepository userRoleRepository; private final RoleRepository roleRepository; + private final UserRepository userRepository; + private final ProvisioningService provisioningService; private final Config config; - public UserRoleController(UserRoleRepository userRoleRepository, RoleRepository roleRepository, Config config) { + public UserRoleController(UserRoleRepository userRoleRepository, + RoleRepository roleRepository, + UserRepository userRepository, + ProvisioningService provisioningService, + Config config) { this.userRoleRepository = userRoleRepository; this.roleRepository = roleRepository; + this.userRepository = userRepository; + this.provisioningService = provisioningService; this.config = config; } @@ -58,6 +71,51 @@ public ResponseEntity> byRole(@PathVariable("roleId") Long roleId return ResponseEntity.ok(userRoles); } + @PostMapping("user_role_provisioning") + public ResponseEntity> userRoleProvisioning(@Validated @RequestBody UserRoleProvisioning userRoleProvisioning, + @Parameter(hidden = true) User apiUser) { + userRoleProvisioning.validate(); + UserPermissions.assertInstitutionAdmin(apiUser); + List roles = userRoleProvisioning.roleIdentifiers.stream() + .map(roleId -> roleRepository.findById(roleId).orElseThrow(NotFoundException::new)) + .toList(); + UserPermissions.assertValidInvitation(apiUser, userRoleProvisioning.intendedAuthority, roles); + Optional userOptional = Optional.empty(); + + if (StringUtils.hasText(userRoleProvisioning.sub)) { + userOptional = userRepository.findBySubIgnoreCase(userRoleProvisioning.sub); + } else if (StringUtils.hasText(userRoleProvisioning.eduPersonPrincipalName)) { + userOptional = userRepository.findByEduPersonPrincipalNameIgnoreCase(userRoleProvisioning.eduPersonPrincipalName); + } else if (StringUtils.hasText(userRoleProvisioning.email)) { + userOptional = userRepository.findByEmailIgnoreCase(userRoleProvisioning.email); + } + //Can't use shorthand notation as there are probably null values + User user = userOptional.orElseGet(() -> userRepository.save(new User(userRoleProvisioning))); + + List newUserRoles = roles.stream() + .map(role -> + user.getUserRoles().stream() + .noneMatch(userRole -> userRole.getRole().getId().equals(role.getId())) ? + user.addUserRole(new UserRole( + apiUser.getName(), + user, + role, + userRoleProvisioning.intendedAuthority, + Instant.now().plus(role.getDefaultExpiryDays(), ChronoUnit.DAYS))) + : null) + .filter(Objects::nonNull) + .toList(); + + userRepository.save(user); + AccessLogger.user(LOG, Event.Created, user); + + provisioningService.newUserRequest(user); + newUserRoles.forEach(userRole -> provisioningService.updateGroupRequest(userRole, OperationType.Add)); + + return Results.createResult(); + } + + @PutMapping("") public ResponseEntity> updateUserRoleExpirationDate(@Validated @RequestBody UpdateUserRole updateUserRole, @Parameter(hidden = true) User user) { diff --git a/server/src/main/java/access/model/User.java b/server/src/main/java/access/model/User.java index daf9058f..80a0009b 100644 --- a/server/src/main/java/access/model/User.java +++ b/server/src/main/java/access/model/User.java @@ -102,6 +102,20 @@ public User(boolean superUser, Map attributes) { this.nameInvariant(attributes); } + public User(UserRoleProvisioning userRoleProvisioning) { + this.sub = resolveSub(userRoleProvisioning); + this.eduPersonPrincipalName = userRoleProvisioning.eduPersonPrincipalName; + this.schacHomeOrganization = userRoleProvisioning.schacHomeOrganization; + this.email = userRoleProvisioning.email; + this.name = userRoleProvisioning.name; + this.givenName = userRoleProvisioning.givenName; + this.familyName = userRoleProvisioning.familyName; + this.nameInvariant(Map.of( + "name", StringUtils.hasText(this.name)? this.name : "", + "preferred_username", "" + )); + } + private void nameInvariant(Map attributes) { String name = (String) attributes.get("name"); String preferredUsername = (String) attributes.get("preferred_username"); @@ -142,9 +156,10 @@ public User(boolean superUser, String eppn, String sub, String schacHomeOrganiza } @JsonIgnore - public void addUserRole(UserRole userRole) { + public UserRole addUserRole(UserRole userRole) { this.userRoles.add(userRole); userRole.setUser(this); + return userRole; } @JsonIgnore @@ -203,4 +218,27 @@ public void updateRemoteAttributes(Map attributes) { public Optional latestUserRole() { return this.userRoles.stream().max(Comparator.comparing(UserRole::getCreatedAt)); } + + @JsonIgnore + public String resolveSub(UserRoleProvisioning userRoleProvisioning) { + String schacHome = null; + String uid = null; + if (StringUtils.hasText(userRoleProvisioning.schacHomeOrganization)) { + schacHome = userRoleProvisioning.schacHomeOrganization; + } + String eppn = userRoleProvisioning.eduPersonPrincipalName; + if (StringUtils.hasText(eppn) && eppn.contains("@")) { + uid = eppn.substring(0, eppn.indexOf("@")); + schacHome = schacHome != null ? schacHome : eppn.substring(eppn.indexOf("@") + 1); + } + String mail = userRoleProvisioning.email; + if (StringUtils.hasText(mail)) { + uid = uid != null ? uid : mail.substring(0, mail.indexOf("@")); + schacHome = schacHome != null ? schacHome : mail.substring(mail.indexOf("@") + 1); + } + if (schacHome == null || uid == null) { + throw new IllegalArgumentException("Can't resolve sub from " + userRoleProvisioning); + } + return String.format("urn:collab:person:%s:%s", schacHome, uid); + } } diff --git a/server/src/main/java/access/model/UserRoleProvisioning.java b/server/src/main/java/access/model/UserRoleProvisioning.java new file mode 100644 index 00000000..e4fc6dec --- /dev/null +++ b/server/src/main/java/access/model/UserRoleProvisioning.java @@ -0,0 +1,33 @@ +package access.model; + +import jakarta.validation.constraints.NotEmpty; +import lombok.AllArgsConstructor; +import lombok.NoArgsConstructor; +import lombok.ToString; +import org.springframework.util.StringUtils; + +import java.util.List; + +@ToString +@AllArgsConstructor +@NoArgsConstructor +public class UserRoleProvisioning { + + @NotEmpty + public List roleIdentifiers; + public Authority intendedAuthority = Authority.GUEST; + public String sub; + public String email; + public String eduPersonPrincipalName; + public String givenName; + public String familyName; + public String name; + public String schacHomeOrganization; + + public void validate() { + if (!StringUtils.hasText(email) && !StringUtils.hasText(eduPersonPrincipalName)) { + throw new IllegalArgumentException("Requires one off: email, eduPersonPrincipalName. Invalid userRoleProvisioning: " + this); + } + } + +} diff --git a/server/src/main/java/access/provision/ProvisioningServiceDefault.java b/server/src/main/java/access/provision/ProvisioningServiceDefault.java index ef042868..d6739765 100644 --- a/server/src/main/java/access/provision/ProvisioningServiceDefault.java +++ b/server/src/main/java/access/provision/ProvisioningServiceDefault.java @@ -31,6 +31,7 @@ import java.util.*; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; @Service @SuppressWarnings("unchecked") @@ -166,14 +167,14 @@ public void updateGroupRequest(UserRole userRole, OperationType operationType) { userRoles = userRoleRepository.findByRole(userRole.getRole()) .stream() .filter(userRoleDB -> userRoleDB.getAuthority().equals(Authority.GUEST) || userRoleDB.isGuestRoleIncluded()) - .toList(); + .collect(Collectors.toCollection(ArrayList::new)); boolean userRolePresent = userRoles.stream().anyMatch(dbUserRole -> dbUserRole.getId().equals(userRole.getId())); if (operationType.equals(OperationType.Add) && !userRolePresent) { userRoles.add(userRole); } else if (operationType.equals(OperationType.Remove) && userRolePresent) { userRoles = userRoles.stream() .filter(dbUserRole -> !dbUserRole.getId().equals(userRole.getId())) - .toList(); + .collect(Collectors.toCollection(ArrayList::new)); } } else { userRoles.add(userRole); diff --git a/server/src/main/java/access/repository/UserRepository.java b/server/src/main/java/access/repository/UserRepository.java index 7fb7d93a..6517b796 100644 --- a/server/src/main/java/access/repository/UserRepository.java +++ b/server/src/main/java/access/repository/UserRepository.java @@ -16,7 +16,9 @@ public interface UserRepository extends JpaRepository { List findByOrganizationGUIDAndInstitutionAdmin(String organizationGUID, boolean institutionAdmin); - List findByUserRoles_role_id(Long roleId); + Optional findByEduPersonPrincipalNameIgnoreCase(String eppn); + + Optional findByEmailIgnoreCase(String email); List findByLastActivityBefore(Instant instant); diff --git a/server/src/test/java/access/api/UserRoleControllerTest.java b/server/src/test/java/access/api/UserRoleControllerTest.java index f2e65103..a4946f91 100644 --- a/server/src/test/java/access/api/UserRoleControllerTest.java +++ b/server/src/test/java/access/api/UserRoleControllerTest.java @@ -2,10 +2,9 @@ import access.AbstractTest; import access.AccessCookieFilter; -import access.model.Authority; -import access.model.Role; -import access.model.UpdateUserRole; -import access.model.UserRole; +import access.exception.NotFoundException; +import access.manage.EntityType; +import access.model.*; import io.restassured.common.mapper.TypeRef; import io.restassured.http.ContentType; import org.junit.jupiter.api.Test; @@ -14,10 +13,11 @@ import java.time.temporal.ChronoUnit; import java.util.List; -import static access.Seed.INVITER_SUB; -import static access.Seed.MANAGE_SUB; +import static access.Seed.*; +import static access.security.SecurityConfig.API_TOKEN_HEADER; import static io.restassured.RestAssured.given; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; class UserRoleControllerTest extends AbstractTest { @@ -120,4 +120,45 @@ void deleteUserRoleNotAllowed() throws Exception { .then() .statusCode(403); } + + @Test + void userRoleProvisioning() throws Exception { + super.stubForManagerProvidersByIdIn(EntityType.SAML20_SP, List.of("1", "2")); + super.stubForManageProviderByOrganisationGUID(ORGANISATION_GUID); + super.stubForManageProvisioning(List.of("1", "2")); + + super.stubForCreateScimUser(); + super.stubForCreateGraphUser(); + super.stubForCreateScimRole(); + super.stubForUpdateScimRole(); + + List roleIdentifiers = List.of( + roleRepository.findByName("Network").get(0).getId(), + roleRepository.findByName("Wiki").get(0).getId() + ); + UserRoleProvisioning userRoleProvisioning = new UserRoleProvisioning( + roleIdentifiers, + Authority.GUEST, + null, + "new_user@domain.org", + null, + null, + null, + "Charly Green", + null + ); + given() + .when() + .header(API_TOKEN_HEADER, API_TOKEN_HASH) + .accept(ContentType.JSON) + .contentType(ContentType.JSON) + .body(userRoleProvisioning) + .post("/api/external/v1/user_roles/user_role_provisioning") + .then() + .statusCode(201); + + User user = userRepository.findBySubIgnoreCase("urn:collab:person:domain.org:new_user").orElseThrow(NotFoundException::new); + assertEquals(2, user.getUserRoles().size()); + } + } \ No newline at end of file