diff --git a/client/package.json b/client/package.json index 8a054dee..10a2febc 100644 --- a/client/package.json +++ b/client/package.json @@ -5,7 +5,7 @@ "scripts": { "dev": "vite", "build": "vite build", - "lint": "eslint .", + "lint": "eslint ./src", "preview": "vite preview", "test": "vitest" }, diff --git a/client/src/pages/RoleForm.jsx b/client/src/pages/RoleForm.jsx index 74c4640d..ffa59ef7 100644 --- a/client/src/pages/RoleForm.jsx +++ b/client/src/pages/RoleForm.jsx @@ -451,6 +451,7 @@ export const RoleForm = () => { validateApplication(index, e.target.value)} diff --git a/client/src/tabs/Tokens.jsx b/client/src/tabs/Tokens.jsx index b75cc1d2..e9dd5c8b 100644 --- a/client/src/tabs/Tokens.jsx +++ b/client/src/tabs/Tokens.jsx @@ -63,7 +63,7 @@ export const Tokens = () => { }); } }); - }, []); + }, [user.superUser]); useEffect(() => { if (!isGuest) { diff --git a/server/src/main/java/invite/aggregation/AttributeAggregatorController.java b/server/src/main/java/invite/aggregation/AttributeAggregatorController.java index 9b4d73c7..12dcda4a 100644 --- a/server/src/main/java/invite/aggregation/AttributeAggregatorController.java +++ b/server/src/main/java/invite/aggregation/AttributeAggregatorController.java @@ -15,13 +15,22 @@ import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.web.bind.annotation.*; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; import java.time.Instant; +import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; import static invite.SwaggerOpenIdConfig.BASIC_AUTHENTICATION_SCHEME_NAME; @@ -31,6 +40,8 @@ public class AttributeAggregatorController { private static final Log LOG = LogFactory.getLog(AttributeAggregatorController.class); + private static final String AUTORISATIE = "autorisatie"; + private static final String ID = "id"; private final UserRepository userRepository; private final Manage manage; @@ -72,19 +83,32 @@ public ResponseEntity>> getGroupMemberships(@PathVariab userRepository.save(user); Map provider = optionalProvider.get(); - List> userRoles = user.getUserRoles().stream() - .filter(userRole -> userRole.getRole().applicationsUsed().stream().anyMatch(application -> application.getManageId().equals(provider.get("id")))) + List> userRoleList = user.getUserRoles().stream() + .filter(userRole -> userRole.getRole().applicationsUsed().stream() + .anyMatch(application -> application.getManageId().equals(provider.get(ID)))) .filter(userRole -> userRole.getAuthority().equals(Authority.GUEST) || userRole.isGuestRoleIncluded()) .map(this::parseUserRole) - .toList(); - LOG.debug(String.format("Returning %o roles for AA request for user: %s and service %s", userRoles.size(), unspecifiedId, spEntityId)); - return ResponseEntity.ok(userRoles); + .collect(Collectors.toCollection(ArrayList::new)); + + List> autorisatieRoles = userRoleList.stream().filter(m -> m.containsKey(AUTORISATIE)).toList(); + if (!autorisatieRoles.isEmpty()) { + Role role = user.getUserRoles().stream() + .map(userRole -> userRole.getRole()) + .filter(r -> StringUtils.hasText(r.getCrmRoleId())) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Won't happen")); + userRoleList.add(Map.of(AUTORISATIE, "urn:mace:surfnet.nl:surfnet.nl:sab:organizationCode:" + role.getCrmOrganisationCode())); + userRoleList.add(Map.of(AUTORISATIE, "urn:mace:surfnet.nl:surfnet.nl:sab:organizationGUID:" + role.getCrmOrganisationId())); + } + LOG.debug(String.format("Returning %o roles for AA request for user: %s and service %s", userRoleList.size(), unspecifiedId, spEntityId)); + + return ResponseEntity.ok(userRoleList); } private Map parseUserRole(UserRole userRole) { Role role = userRole.getRole(); String urn = GroupURN.urnFromRole(groupUrnPrefix, role); - return Map.of("id", urn); + return Map.of(StringUtils.hasText(role.getCrmRoleId()) ? AUTORISATIE : ID, urn); } } diff --git a/server/src/main/java/invite/api/InvitationController.java b/server/src/main/java/invite/api/InvitationController.java index e2904f89..ac8ec23b 100644 --- a/server/src/main/java/invite/api/InvitationController.java +++ b/server/src/main/java/invite/api/InvitationController.java @@ -4,12 +4,24 @@ import invite.exception.InvitationEmailMatchingException; import invite.exception.InvitationExpiredException; import invite.exception.InvitationStatusException; +import invite.exception.InvitationUniqueCrmOrganisationException; import invite.exception.NotFoundException; import invite.logging.AccessLogger; import invite.logging.Event; import invite.mail.MailBox; import invite.manage.Manage; -import invite.model.*; +import invite.model.AcceptInvitation; +import invite.model.Authority; +import invite.model.Invitation; +import invite.model.InvitationRequest; +import invite.model.InvitationResponse; +import invite.model.InvitationRole; +import invite.model.Role; +import invite.model.Status; +import invite.model.StatusResponse; +import invite.model.User; +import invite.model.UserRole; +import invite.model.UserRoleAudit; import invite.provision.Provisioning; import invite.provision.ProvisioningService; import invite.provision.graph.GraphResponse; @@ -49,12 +61,26 @@ import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; import java.net.URLDecoder; import java.nio.charset.Charset; import java.time.Instant; -import java.util.*; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; import java.util.stream.Collectors; import static invite.SwaggerOpenIdConfig.API_TOKENS_SCHEME_NAME; @@ -280,6 +306,8 @@ public ResponseEntity> accept(@Validated @RequestBody Accept }); checkEmailEquality(user, invitation); + checkCrmUniqueOrganisation(user, invitation); + user.setLastActivity(Instant.now()); invitation.setStatus(Status.ACCEPTED); @@ -524,5 +552,23 @@ private void checkEmailEquality(User user, Invitation invitation) { } } + private void checkCrmUniqueOrganisation(User user, Invitation invitation) { + if (StringUtils.hasText(invitation.getCrmContactId()) && user.getUserRoles().stream() + .map(userRole -> userRole.getRole().getCrmOrganisationId()) + .anyMatch(crmOrganisationId -> StringUtils.hasText(crmOrganisationId) && + invitation.getRoles().stream() + .map(invitationRole -> invitationRole.getRole()) + .anyMatch(role -> StringUtils.hasText(role.getCrmOrganisationId()) && !role.getCrmOrganisationId().equals(crmOrganisationId)))) { + throw new InvitationUniqueCrmOrganisationException( + String.format("User %s is not allowed to accept an invitation from Organisation %s, because it already has roles for Organisation %s", + user.getEmail(), + user.getUserRoles().stream() + .map(userRole -> userRole.getRole().getCrmOrganisationId()) + .toList(), + invitation.getRoles().stream() + .map(invitationRole -> invitationRole.getRole().getCrmOrganisationId()) + .toList())); + } + } } diff --git a/server/src/main/java/invite/api/InvitationOperations.java b/server/src/main/java/invite/api/InvitationOperations.java index 47cc606f..7c41140c 100644 --- a/server/src/main/java/invite/api/InvitationOperations.java +++ b/server/src/main/java/invite/api/InvitationOperations.java @@ -6,6 +6,7 @@ import invite.logging.AccessLogger; import invite.logging.Event; import invite.mail.MailBox; +import invite.manage.Manage; import invite.model.*; import invite.repository.InvitationRepository; import invite.security.RemoteUser; @@ -123,7 +124,7 @@ public ResponseEntity sendInvitation(InvitationRequest invit if (!invitationRequest.isSuppressSendingEmails()) { invitations.forEach(invitation -> { - Optional idpName = this.identityProviderName(invitation); + Optional idpName = identityProviderName(this.invitationResource.getManage(), invitation); mailBox.sendInviteMail(user == null ? remoteUser : user, invitation, groupedProviders, invitationRequest.getLanguage(), idpName); }); @@ -174,7 +175,7 @@ public ResponseEntity> resendInvitation(Long id, List groupedProviders = this.invitationResource.getManage().getGroupedProviders(requestedRoles); Provisionable provisionable = user != null ? user : remoteUser; - Optional idpName = identityProviderName(invitation); + Optional idpName = identityProviderName(this.invitationResource.getManage(), invitation); this.invitationResource.getMailBox() .sendInviteMail(provisionable, @@ -192,9 +193,9 @@ public ResponseEntity> resendInvitation(Long id, return Results.createResult(); } - private Optional identityProviderName(Invitation invitation) { + public static Optional identityProviderName(Manage manage, Invitation invitation) { return Optional.ofNullable(invitation.getOrganizationGUID()) - .map(organisationGUID -> this.invitationResource.getManage().identityProvidersByInstitutionalGUID(organisationGUID)) + .map(organisationGUID -> manage.identityProvidersByInstitutionalGUID(organisationGUID)) .stream() .flatMap(Collection::stream) .findFirst() diff --git a/server/src/main/java/invite/crm/CRMContact.java b/server/src/main/java/invite/crm/CRMContact.java new file mode 100644 index 00000000..699dd2a3 --- /dev/null +++ b/server/src/main/java/invite/crm/CRMContact.java @@ -0,0 +1,28 @@ +package invite.crm; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +import java.util.List; + +@Getter +@Setter +@ToString +@NoArgsConstructor +@AllArgsConstructor +public class CRMContact { + + private String uid; + private String schacHomeOrganisation; + private boolean suppressInvitation; + private String contactId; + private String firstname; + private String middlename; + private String surname; + private String email; + private CRMOrganisation organisation; + private List roles; +} diff --git a/server/src/main/java/invite/crm/CRMController.java b/server/src/main/java/invite/crm/CRMController.java new file mode 100644 index 00000000..7919daf7 --- /dev/null +++ b/server/src/main/java/invite/crm/CRMController.java @@ -0,0 +1,284 @@ +package invite.crm; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import invite.audit.UserRoleAuditService; +import invite.config.HashGenerator; +import invite.exception.InvalidInputException; +import invite.exception.NotFoundException; +import invite.mail.MailBox; +import invite.manage.EntityType; +import invite.manage.Manage; +import invite.model.Application; +import invite.model.ApplicationUsage; +import invite.model.Authority; +import invite.model.GroupedProviders; +import invite.model.Invitation; +import invite.model.InvitationRole; +import invite.model.Language; +import invite.model.Provisionable; +import invite.model.Role; +import invite.model.User; +import invite.model.UserRole; +import invite.model.UserRoleAudit; +import invite.provision.ProvisioningService; +import invite.provision.scim.OperationType; +import invite.repository.ApplicationRepository; +import invite.repository.RoleRepository; +import invite.repository.UserRepository; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.jspecify.annotations.NonNull; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.Resource; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.CollectionUtils; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import static invite.api.InvitationOperations.identityProviderName; + +@RestController +@RequestMapping(value = {"/api/internal/v1/crm"}, produces = MediaType.APPLICATION_JSON_VALUE) +@Transactional +public class CRMController { + + private static final Log LOG = LogFactory.getLog(CRMController.class); + + private final String collabPersonPrefix; + private final UserRepository userRepository; + private final RoleRepository roleRepository; + private final ApplicationRepository applicationRepository; + private final ProvisioningService provisioningService; + private final MailBox mailBox; + private final Manage manage; + private final Map crmConfig; + private final UserRoleAuditService userRoleAuditService; + private final Provisionable provisionable = () -> "SURF CRM"; + + + @SuppressWarnings("unchecked") + public CRMController(@Value("${crm.collab-person-prefix}") String collabPersonPrefix, + @Value("${crm.crm-config-resource}") Resource crmConfigResource, + UserRepository userRepository, + RoleRepository roleRepository, + ApplicationRepository applicationRepository, + ProvisioningService provisioningService, + ObjectMapper objectMapper, + MailBox mailBox, Manage manage, + UserRoleAuditService userRoleAuditService) throws IOException { + this.userRepository = userRepository; + this.collabPersonPrefix = collabPersonPrefix; + this.roleRepository = roleRepository; + this.applicationRepository = applicationRepository; + this.provisioningService = provisioningService; + this.mailBox = mailBox; + this.manage = manage; + this.userRoleAuditService = userRoleAuditService; + Map> crmConfigRaw = objectMapper.readValue(crmConfigResource.getInputStream(), new TypeReference<>() { + }); + this.crmConfig = crmConfigRaw.entrySet().stream() + .map(entry -> new CrmConfigEntry(entry.getKey(), (String) entry.getValue().get("name"), + ((List>) entry.getValue().get("applications")).stream() + .map(application -> new CrmManageIdentifier( + EntityType.valueOf(application.get("manageType").toUpperCase()), + application.get("manageEntityId"))).toList())) + .collect(Collectors.toMap( + crmConfigEntry -> crmConfigEntry.code(), + crmConfigEntry -> crmConfigEntry + )); + + } + + @PostMapping("") + public ResponseEntity contact(@RequestBody CRMContact crmContact) { + LOG.debug("POST /api/external/v1/crm: " + crmContact); + + boolean created; + + if (crmContact.isSuppressInvitation()) { + if (!StringUtils.hasText(crmContact.getSchacHomeOrganisation()) || !StringUtils.hasText(crmContact.getUid())) { + throw new InvalidInputException( + "Missing schacHomeOrganisation or uid in crmContact with sendInvitation false: " + crmContact); + } + created = provisionUser(crmContact); + } else { + created = sendInvitation(crmContact); + } + + return ResponseEntity.ok().body(created ? "created" : "updated"); + } + + @DeleteMapping("") + public ResponseEntity delete(@RequestBody CRMContact crmContact) { + LOG.debug("DELETE /api/external/v1/crm: " + crmContact); + + List users = userRepository.findByCrmContactId(crmContact.getContactId()); + users.forEach(user -> { + LOG.info("Deleting CRM user: " + user.getEmail()); + this.provisioningService.deleteUserRequest(user); + this.userRepository.delete(user); + }); + + return ResponseEntity.ok().body("deleted"); + } + + private boolean provisionUser(CRMContact crmContact) { + String sub = constructSub(crmContact); + Optional optionalUser = userRepository.findBySubIgnoreCase(sub); + User user = optionalUser.orElseGet(() -> createUser(crmContact, sub)); + List newCrmRoles = syncCrmRoles(crmContact, user); + + List roles = convertCrmRolesToInviteRoles(crmContact, newCrmRoles); + roles + .forEach(role -> { + UserRole userRole = new UserRole(Authority.GUEST, role); + user.addUserRole(userRole); + + userRoleAuditService.logAction(userRole, UserRoleAudit.ActionType.ADD); + this.provisioningService.updateGroupRequest(userRole, OperationType.add); + }); + userRepository.save(user); + return optionalUser.isEmpty(); + } + + private User createUser(CRMContact crmContact, String sub) { + String middleName = crmContact.getMiddlename(); + String surName = crmContact.getSurname(); + User unsavedUser = new User( + false, + crmContact.getEmail(), + sub, + crmContact.getSchacHomeOrganisation(), + crmContact.getFirstname(), + StringUtils.hasText(middleName) ? String.format("%s %s", middleName, surName) : surName, + crmContact.getEmail()); + unsavedUser.setCrmContactId(crmContact.getContactId()); + User user = userRepository.save(unsavedUser); + this.provisioningService.newUserRequest(user); + return user; + } + + private String constructSub(CRMContact crmContact) { + return String.format("%s:%s:%s", collabPersonPrefix, crmContact.getSchacHomeOrganisation(), crmContact.getUid()); + } + + private List syncCrmRoles(CRMContact crmContact, User user) { + // Removes roles no longer present in CRM + user.getUserRoles().removeIf(userRole -> { + Role role = userRole.getRole(); + //Ensure not to delete regular non-CRM roles + boolean isDeleted = StringUtils.hasText(role.getCrmRoleId()) && crmContact.getRoles().stream() + .noneMatch(crmRole -> crmRole.getRoleId().equals(role.getCrmRoleId())); + if (isDeleted) { + this.provisioningService.updateGroupRequest(userRole, OperationType.remove); + } + return isDeleted; + }); + List currentRoles = user.getUserRoles().stream() + .map(userRole -> userRole.getRole()) + .toList(); + // Return all the new CRM roles + return crmContact.getRoles().stream() + .filter(crmRole -> currentRoles.stream() + .noneMatch(role -> crmRole.getRoleId().equalsIgnoreCase(role.getCrmRoleId()))) + .toList(); + } + + private boolean sendInvitation(CRMContact crmContact) { + String sub = constructSub(crmContact); + Optional optionalUser = userRepository.findBySubIgnoreCase(sub); + List newCrmRoles = syncCrmRoles(crmContact, optionalUser.orElse(new User())); + //Only save the user when the user already existed + optionalUser.ifPresent(user -> userRepository.save(user)); + //If there are no new roles, then we can't do anything + if (!CollectionUtils.isEmpty(newCrmRoles)) { + // Maps CRM roles to existing or new roles + List roles = convertCrmRolesToInviteRoles(crmContact, newCrmRoles); + List groupedProviders = manage.getGroupedProviders(roles); + Set invitationRoles = roles.stream() + .map(role -> new InvitationRole(role)) + .collect(Collectors.toSet()); + Invitation invitation = createInvitation(crmContact, invitationRoles); + invitation.setOrganizationGUID(crmContact.getOrganisation().getOrganisationId()); + Optional idpName = identityProviderName(manage, invitation); + mailBox.sendInviteMail(this.provisionable, invitation, groupedProviders, Language.en, idpName); + } + return optionalUser.isEmpty(); + } + + private @NonNull List convertCrmRolesToInviteRoles(CRMContact crmContact, List newCrmRoles) { + return newCrmRoles.stream() + .map(crmRole -> roleRepository.findByCrmRoleId(crmRole.getRoleId()) + .orElseGet(() -> this.createRole(crmContact.getOrganisation(), crmRole))) + .toList(); + } + + private Role createRole(CRMOrganisation crmOrganisation, CRMRole crmRole) { + CrmConfigEntry crmConfigEntry = this.crmConfig.get(crmRole.getSabCode()); + if (crmConfigEntry == null) { + throw new InvalidInputException("CRM sabCode is not configured: " + crmRole.getSabCode()); + } + Set applicationUsages = crmConfigEntry.crmManageIdentifiers().stream() + .map(crmManageIdentifier -> manage + .providerByEntityID(crmManageIdentifier.manageType(), crmManageIdentifier.manageEntityId()) + .orElseThrow(() -> new NotFoundException("Manage entity not found: " + crmManageIdentifier))) + .map(provider -> { + String manageId = (String) provider.get("id"); + EntityType manageType = EntityType.valueOf(((String) provider.get("type")).toUpperCase()); + return applicationRepository.findByManageIdAndManageTypeOrderById(manageId, manageType) + .orElseGet( + () -> applicationRepository.save(new Application(manageId, manageType, (String) provider.get("url")))); + }) + .map(application -> new ApplicationUsage(application, application.getLandingPage())) + .collect(Collectors.toSet()); + Role unsavedRole = new Role( + String.format("%s for %s", crmConfigEntry.name(), crmOrganisation.getName()), + String.format("CRM role %s for organisation %s", crmConfigEntry.name(), crmOrganisation.getName()), + applicationUsages, + 365, + true, + false + ); + unsavedRole.setCrmRoleId(crmRole.getRoleId()); + unsavedRole.setCrmRoleName(crmConfigEntry.name()); + unsavedRole.setCrmOrganisationId(crmOrganisation.getOrganisationId()); + unsavedRole.setCrmOrganisationCode(crmOrganisation.getAbbrev()); + Role role = roleRepository.save(unsavedRole); + this.provisioningService.newGroupRequest(role); + return role; + } + + private Invitation createInvitation(CRMContact crmContact, Set invitationRoles) { + return new Invitation( + Authority.GUEST, + HashGenerator.generateRandomHash(), + crmContact.getEmail(), + true, + false, + null, + false, + null, + Language.en, + null, + null,//Defauly expiryDate + null, + invitationRoles, + null + ); + + } +} diff --git a/server/src/main/java/invite/crm/CRMOrganisation.java b/server/src/main/java/invite/crm/CRMOrganisation.java new file mode 100644 index 00000000..92ce341a --- /dev/null +++ b/server/src/main/java/invite/crm/CRMOrganisation.java @@ -0,0 +1,20 @@ +package invite.crm; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@ToString +@NoArgsConstructor +@AllArgsConstructor +public class CRMOrganisation { + + private String organisationId; + private String abbrev; + private String name; + +} diff --git a/server/src/main/java/invite/crm/CRMRole.java b/server/src/main/java/invite/crm/CRMRole.java new file mode 100644 index 00000000..1683ee9e --- /dev/null +++ b/server/src/main/java/invite/crm/CRMRole.java @@ -0,0 +1,20 @@ +package invite.crm; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@ToString +@NoArgsConstructor +@AllArgsConstructor +public class CRMRole { + + private String roleId; + private String sabCode; + private String name; + +} diff --git a/server/src/main/java/invite/crm/CrmConfigEntry.java b/server/src/main/java/invite/crm/CrmConfigEntry.java new file mode 100644 index 00000000..ec79cc4c --- /dev/null +++ b/server/src/main/java/invite/crm/CrmConfigEntry.java @@ -0,0 +1,6 @@ +package invite.crm; + +import java.util.List; + +public record CrmConfigEntry(String code, String name, List crmManageIdentifiers) { +} diff --git a/server/src/main/java/invite/crm/CrmManageIdentifier.java b/server/src/main/java/invite/crm/CrmManageIdentifier.java new file mode 100644 index 00000000..3bb615d3 --- /dev/null +++ b/server/src/main/java/invite/crm/CrmManageIdentifier.java @@ -0,0 +1,8 @@ +package invite.crm; + + +import invite.manage.EntityType; + + +public record CrmManageIdentifier(EntityType manageType, String manageEntityId) { +} \ No newline at end of file diff --git a/server/src/main/java/invite/exception/InvitationUniqueCrmOrganisationException.java b/server/src/main/java/invite/exception/InvitationUniqueCrmOrganisationException.java new file mode 100644 index 00000000..60e16228 --- /dev/null +++ b/server/src/main/java/invite/exception/InvitationUniqueCrmOrganisationException.java @@ -0,0 +1,13 @@ +package invite.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.security.core.AuthenticationException; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus(HttpStatus.NOT_ACCEPTABLE) +public class InvitationUniqueCrmOrganisationException extends AuthenticationException { + + public InvitationUniqueCrmOrganisationException(String msg) { + super(msg); + } +} diff --git a/server/src/main/java/invite/mail/MailBox.java b/server/src/main/java/invite/mail/MailBox.java index 5a458843..b1a4772e 100644 --- a/server/src/main/java/invite/mail/MailBox.java +++ b/server/src/main/java/invite/mail/MailBox.java @@ -79,7 +79,7 @@ public void sendInviteMail(Provisionable provisionable, Invitation invitation, variables.put("institutionName", "SURF"); variables.put("isInstitutionSurf", true); } - optionalIdpName.ifPresent(idpName -> variables.put("idpName", idpName)); + optionalIdpName.ifPresent(idpName -> variables.put("idpName", idpName)); variables.put("roles", splitListSemantically(invitation.getRoles().stream() .map(invitationRole -> invitationRole.getRole().getName()).toList())); if (invitation.getRoles().stream() diff --git a/server/src/main/java/invite/model/Invitation.java b/server/src/main/java/invite/model/Invitation.java index 6798236f..adecf0dc 100644 --- a/server/src/main/java/invite/model/Invitation.java +++ b/server/src/main/java/invite/model/Invitation.java @@ -89,6 +89,9 @@ public class Invitation implements Serializable { @Column(name = "remote_api_user") private String remoteApiUser; + @Column(name = "crm_contact_id") + private String crmContactId; + @OneToMany(mappedBy = "invitation", orphanRemoval = true, fetch = FetchType.EAGER, cascade = CascadeType.ALL) private Set roles = new HashSet<>(); diff --git a/server/src/main/java/invite/model/Role.java b/server/src/main/java/invite/model/Role.java index 7b77bca6..c05b477a 100644 --- a/server/src/main/java/invite/model/Role.java +++ b/server/src/main/java/invite/model/Role.java @@ -70,6 +70,18 @@ public class Role implements Serializable, Provisionable { @Column(name = "inviter_display_name") private String inviterDisplayName; + @Column(name = "crm_organisation_id") + private String crmOrganisationId; + + @Column(name = "crm_organisation_code") + private String crmOrganisationCode; + + @Column(name = "crm_role_id") + private String crmRoleId; + + @Column(name = "crm_role_name") + private String crmRoleName; + @Formula(value = "(SELECT COUNT(*) FROM user_roles ur WHERE ur.role_id=id)") private Long userRoleCount; diff --git a/server/src/main/java/invite/model/User.java b/server/src/main/java/invite/model/User.java index bf2aed13..a1d4f2ad 100644 --- a/server/src/main/java/invite/model/User.java +++ b/server/src/main/java/invite/model/User.java @@ -84,6 +84,9 @@ public class User implements Serializable, Provisionable { @Column(name = "last_activity") private Instant lastActivity; + @Column(name = "crm_contact_id") + private String crmContactId; + @OneToMany(mappedBy = "user", orphanRemoval = true, fetch = FetchType.EAGER, cascade = CascadeType.ALL) private Set userRoles = new HashSet<>(); @@ -140,6 +143,19 @@ public User(UserRoleProvisioning userRoleProvisioning) { )); } + public User(boolean superUser, String eppn, String sub, String schacHomeOrganization, String givenName, String familyName, String email) { + this.superUser = superUser; + this.eduPersonPrincipalName = eppn; + this.sub = sub; + this.schacHomeOrganization = schacHomeOrganization; + this.givenName = givenName; + this.familyName = familyName; + this.name = String.format("%s %s", givenName, familyName); + this.email = email; + this.createdAt = Instant.now(); + this.lastActivity = Instant.now(); + } + private void nameInvariant(Map attributes) { String name = (String) attributes.get("name"); String preferredUsername = (String) attributes.get("preferred_username"); @@ -170,19 +186,6 @@ public void nameInvariant() { } } - public User(boolean superUser, String eppn, String sub, String schacHomeOrganization, String givenName, String familyName, String email) { - this.superUser = superUser; - this.eduPersonPrincipalName = eppn; - this.sub = sub; - this.schacHomeOrganization = schacHomeOrganization; - this.givenName = givenName; - this.familyName = familyName; - this.name = String.format("%s %s", givenName, familyName); - this.email = email; - this.createdAt = Instant.now(); - this.lastActivity = Instant.now(); - } - @JsonIgnore public UserRole addUserRole(UserRole userRole) { this.userRoles.add(userRole); diff --git a/server/src/main/java/invite/provision/ProvisioningServiceDefault.java b/server/src/main/java/invite/provision/ProvisioningServiceDefault.java index cea050e9..afefeab2 100644 --- a/server/src/main/java/invite/provision/ProvisioningServiceDefault.java +++ b/server/src/main/java/invite/provision/ProvisioningServiceDefault.java @@ -68,8 +68,6 @@ @SuppressWarnings("unchecked") public class ProvisioningServiceDefault implements ProvisioningService { - private final RoleRepository roleRepository; - private enum APIType { USER_API("Users"), GROUP_API("Groups"); @@ -111,7 +109,7 @@ public ProvisioningServiceDefault(UserRoleRepository userRoleRepository, EduID eduID, @Value("${voot.group_urn_domain}") String groupUrnPrefix, @Value("${config.eduid-idp-schac-home-organization}") String eduidIdpSchacHomeOrganization, - @Value("${config.server-url}") String serverBaseURL, RoleRepository roleRepository) { + @Value("${config.server-url}") String serverBaseURL) { this.userRoleRepository = userRoleRepository; this.remoteProvisionedUserRepository = remoteProvisionedUserRepository; this.remoteProvisionedGroupRepository = remoteProvisionedGroupRepository; @@ -126,7 +124,6 @@ public ProvisioningServiceDefault(UserRoleRepository userRoleRepository, JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(); requestFactory.setReadTimeout(Duration.ofMinutes(1)); restTemplate.setRequestFactory(requestFactory); - this.roleRepository = roleRepository; } @Override diff --git a/server/src/main/java/invite/provision/scim/GroupURN.java b/server/src/main/java/invite/provision/scim/GroupURN.java index 4c8af7e0..8ac457c3 100644 --- a/server/src/main/java/invite/provision/scim/GroupURN.java +++ b/server/src/main/java/invite/provision/scim/GroupURN.java @@ -2,6 +2,7 @@ import invite.model.Role; +import org.springframework.util.StringUtils; import java.text.Normalizer; @@ -22,7 +23,12 @@ public static String sanitizeRoleShortName(String shortName) { } public static String urnFromRole(String groupUrnPrefix, Role role) { - return role.isTeamsOrigin() ? role.getUrn() : String.format("%s:%s:%s", + if (role.isTeamsOrigin()) { + return role.getUrn(); + } else if (StringUtils.hasText(role.getCrmRoleId())) { + return "urn:mace:surfnet.nl:surfnet.nl:sab:role:" + role.getCrmRoleName(); + } + return String.format("%s:%s:%s", groupUrnPrefix, role.getIdentifier(), role.getShortName()); diff --git a/server/src/main/java/invite/repository/RoleRepository.java b/server/src/main/java/invite/repository/RoleRepository.java index 5ee8ddb8..7692faf5 100644 --- a/server/src/main/java/invite/repository/RoleRepository.java +++ b/server/src/main/java/invite/repository/RoleRepository.java @@ -96,6 +96,8 @@ SELECT COUNT(r.id) FROM roles r WHERE r.organization_guid = ?1 Optional findByName(String name); + Optional findByCrmRoleId(String crmRoleId); + @Override default String rewrite(String query, Sort sort) { Sort.Order userRoleCount = sort.getOrderFor("userRoleCount"); diff --git a/server/src/main/java/invite/repository/UserRepository.java b/server/src/main/java/invite/repository/UserRepository.java index 0dc9a9ea..5ff0a9a1 100644 --- a/server/src/main/java/invite/repository/UserRepository.java +++ b/server/src/main/java/invite/repository/UserRepository.java @@ -19,6 +19,8 @@ public interface UserRepository extends JpaRepository, QueryRewriter Optional findBySubIgnoreCase(String sub); + List findByCrmContactId(String crmContactId); + List findByOrganizationGUIDAndInstitutionAdmin(String organizationGUID, boolean institutionAdmin); List findBySuperUserTrue(); diff --git a/server/src/main/java/invite/security/SecurityConfig.java b/server/src/main/java/invite/security/SecurityConfig.java index ad6fd9ca..6ce77df0 100644 --- a/server/src/main/java/invite/security/SecurityConfig.java +++ b/server/src/main/java/invite/security/SecurityConfig.java @@ -16,6 +16,7 @@ import org.springframework.core.env.Environment; import org.springframework.core.env.Profiles; import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.security.authorization.AuthorizationDecision; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; @@ -51,6 +52,7 @@ public class SecurityConfig { public static final String API_TOKEN_HEADER = "X-API-TOKEN"; + public static final String API_KEY_HEADER = "API-KEY"; private final String eduidEntityId; private final String introspectionUri; @@ -62,6 +64,7 @@ public class SecurityConfig { private final ExternalApiConfiguration externalApiConfiguration; private final Environment environment; private final Manage manage; + private final String crmApiKeyHeader; private final RequestHeaderRequestMatcher apiTokenRequestMatcher = new RequestHeaderRequestMatcher(API_TOKEN_HEADER); @@ -74,7 +77,9 @@ public SecurityConfig(ClientRegistrationRepository clientRegistrationRepository, @Value("${config.eduid-entity-id}") String eduidEntityId, @Value("${oidcng.introspect-url}") String introspectionUri, @Value("${oidcng.resource-server-id}") String clientId, - @Value("${oidcng.resource-server-secret}") String secret, Manage manage) { + @Value("${oidcng.resource-server-secret}") String secret, + Manage manage, + @Value("${crm.api-key-header}") String crmApiKeyHeader) { this.clientRegistrationRepository = clientRegistrationRepository; this.invitationRepository = invitationRepository; this.provisioningService = provisioningService; @@ -85,6 +90,7 @@ public SecurityConfig(ClientRegistrationRepository clientRegistrationRepository, this.externalApiConfiguration = externalApiConfiguration; this.environment = environment; this.manage = manage; + this.crmApiKeyHeader = crmApiKeyHeader; } @Configuration @@ -247,6 +253,28 @@ SecurityFilterChain jwtSecurityFilterChain(HttpSecurity http) throws Exception { return http.build(); } + @Bean + @Order(4) + public SecurityFilterChain internalCRMApiSecurityFilterChain(HttpSecurity http) throws Exception { + http + .securityMatcher("/api/internal/v1/crm/**") + .sessionManagement(session -> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + .csrf(csrf -> csrf.disable()) + .authorizeHttpRequests(auth -> + auth.anyRequest().access( + (authentication, context) -> { + String headerValue = context.getRequest().getHeader(API_KEY_HEADER); + boolean granted = crmApiKeyHeader.equals(headerValue); + return new AuthorizationDecision(granted); + } + ) + ); + + return http.build(); + } + @Bean public UserDetailsService userDetailsService() { return new ExtendedInMemoryUserDetailsManager(externalApiConfiguration.getRemoteUsers()); diff --git a/server/src/main/resources/application.yml b/server/src/main/resources/application.yml index bc47e709..28caff4f 100644 --- a/server/src/main/resources/application.yml +++ b/server/src/main/resources/application.yml @@ -100,6 +100,13 @@ oidcng: resource-server-secret: secret base-url: http://localhost:8888 +crm: + api-key-header: secret + collab-person-prefix: "urn:collab:person" + # The crm-config-resource can an absolute file path, e.g. file:///opt/openconext/invite/crm_config.json or + # a classpath entry + crm-config-resource: "classpath:/crm/crm_config.json" + super-admin: users: - "urn:collab:person:example.com:admin" diff --git a/server/src/main/resources/crm/crm_config.json b/server/src/main/resources/crm/crm_config.json new file mode 100644 index 00000000..136458d1 --- /dev/null +++ b/server/src/main/resources/crm/crm_config.json @@ -0,0 +1,50 @@ +{ + "AAI": { + "name": "AAIverantwoordelijke", + "roleId": "fc89aa87-07e4-e811-8100-005056956c1a", + "applications": [ + { + "manageEntityId": "https://wiki", + "manageType": "saml20_sp" + }, + { + "manageEntityId": "https://network", + "manageType": "saml20_sp" + } + ] + }, + "BVW": { + "name": "Beveiligingsverantwoordelijke", + "roleId": "92b2b379-07e4-e811-8100-005056956c1a", + "applications": [ + { + "manageEntityId": "https://storage", + "manageType": "saml20_sp" + }, + { + "manageEntityId": "https://calendar", + "manageType": "oidc10_rp" + } + ] + }, + "CONBEH": { + "name": "SURFconextbeheerder", + "roleId": "5e17b508-08e4-e811-8100-005056956c1a", + "applications": [ + { + "manageEntityId": "https://research", + "manageType": "saml20_sp" + } + ] + }, + "CONVER": { + "name": "SURFconextverantwoordelijke", + "roleId": "cf652619-08e4-e811-8100-005056956c1a", + "applications": [ + { + "manageEntityId": "https://cloud", + "manageType": "oidc10_rp" + } + ] + } +} diff --git a/server/src/main/resources/db/mysql/migration/V55_0__crm_roles_users.sql b/server/src/main/resources/db/mysql/migration/V55_0__crm_roles_users.sql new file mode 100644 index 00000000..98ea26f3 --- /dev/null +++ b/server/src/main/resources/db/mysql/migration/V55_0__crm_roles_users.sql @@ -0,0 +1,18 @@ +ALTER TABLE `roles` + ADD `crm_organisation_id` varchar(255) DEFAULT NULL, + ADD `crm_organisation_code` varchar(255) DEFAULT NULL, + ADD `crm_role_id` varchar(255) DEFAULT NULL, + ADD `crm_role_name` varchar(255) DEFAULT NULL; + +CREATE INDEX `roles_crm_organisation_id_index` ON `roles` (`crm_organisation_id`); +CREATE INDEX `roles_crm_role_id_index` ON `roles` (`crm_role_id`); + +ALTER TABLE `users` + ADD `crm_contact_id` varchar(255) DEFAULT NULL; + +CREATE INDEX `users_crm_contact_id_index` ON `users` (`crm_contact_id`); + +ALTER TABLE `invitations` + ADD `crm_contact_id` varchar(255) DEFAULT NULL; + +CREATE INDEX `invitations_crm_contact_id_index` ON `invitations` (`crm_contact_id`); \ No newline at end of file diff --git a/server/src/test/java/invite/AbstractMailTest.java b/server/src/test/java/invite/AbstractMailTest.java index a70ac8b7..fb433ac8 100644 --- a/server/src/test/java/invite/AbstractMailTest.java +++ b/server/src/test/java/invite/AbstractMailTest.java @@ -24,9 +24,10 @@ "spring.security.oauth2.client.provider.oidcng.user-info-uri=http://localhost:8081/user-info", "spring.security.oauth2.client.provider.oidcng.jwk-set-uri=http://localhost:8081/jwk-set", "email.enabled=true", - "spring.task.scheduling.enabled=false", "manage.url: http://localhost:8081", - "manage.enabled: true" + "myconext.uri: http://localhost:8081/myconext/api/invite/provision-eduid", + "manage.enabled: true", + "spring.task.scheduling.enabled=false", }) public class AbstractMailTest extends AbstractTest { diff --git a/server/src/test/java/invite/AbstractTest.java b/server/src/test/java/invite/AbstractTest.java index 8be8cde8..2ef997c7 100644 --- a/server/src/test/java/invite/AbstractTest.java +++ b/server/src/test/java/invite/AbstractTest.java @@ -1,6 +1,9 @@ package invite; import invite.config.HashGenerator; +import invite.crm.CRMContact; +import invite.crm.CRMOrganisation; +import invite.crm.CRMRole; import invite.eduid.EduIDProvision; import invite.manage.EntityType; import invite.manage.LocalManage; @@ -107,6 +110,8 @@ public abstract class AbstractTest { public static final String API_TOKEN_SUPER_USER_HASH = HashGenerator.generateToken(); public static final String API_TOKEN_INVITER_USER_HASH = HashGenerator.generateToken(); public static final String API_TOKEN_LEGACY_HASH = HashGenerator.generateToken(); + public static final String CRM_CONTACT_ID = "5B5A4230-7A67-46E9-9EE1-95C6F5CACA4A"; + @Value("${manage.staticManageDirectory}") private String staticManageDirectory; @@ -574,6 +579,25 @@ protected UnaryOperator> institutionalAdminEntitlementOperat }; } + protected CRMContact getCrmContact(CRMRole crmRole, String uid, String schacHomeOrganisation, boolean suppressInvitation) { + return new CRMContact( + uid, + schacHomeOrganisation, + suppressInvitation, + "contactId", + "John", + "from", + "Doe", + "jdoe@example.com", + new CRMOrganisation( + "organisationId", + "abbrec", + "Inc. Corporated" + ), + List.of(crmRole) + ); + } + protected Set application(String manageId, EntityType entityType) { Application application = applicationRepository.findByManageIdAndManageTypeOrderById(manageId, entityType). orElseGet(() -> applicationRepository.save(new Application(manageId, entityType))); @@ -611,6 +635,7 @@ private void doSeed() { guest.setEduId(UUID.randomUUID().toString()); User kbUser = new User(false, KB_USER_SUB, KB_USER_SUB, "kb.nl", "George", "Best", "gb@kb.nl"); + kbUser.setCrmContactId(CRM_CONTACT_ID); doSave(this.userRepository, superUser, institutionAdmin, manager, inviter, wikiInviter, guest, kbUser); Role wiki = diff --git a/server/src/test/java/invite/aggregation/AttributeAggregatorControllerTest.java b/server/src/test/java/invite/aggregation/AttributeAggregatorControllerTest.java index 41267424..0acfb67e 100644 --- a/server/src/test/java/invite/aggregation/AttributeAggregatorControllerTest.java +++ b/server/src/test/java/invite/aggregation/AttributeAggregatorControllerTest.java @@ -1,16 +1,20 @@ package invite.aggregation; +import com.fasterxml.jackson.core.JsonProcessingException; import invite.AbstractTest; +import invite.crm.CRMContact; +import invite.crm.CRMRole; import invite.manage.EntityType; -import com.fasterxml.jackson.core.JsonProcessingException; import io.restassured.common.mapper.TypeRef; import io.restassured.http.ContentType; import org.junit.jupiter.api.Test; import java.util.List; import java.util.Map; +import java.util.stream.Stream; import static invite.manage.EntityType.SAML20_SP; +import static invite.security.SecurityConfig.API_KEY_HEADER; import static io.restassured.RestAssured.given; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -113,4 +117,52 @@ void manageDown() { }); assertEquals(0, roles.size()); } + + @Test + void attributeAggregationCRMRole() throws JsonProcessingException { + CRMRole crmRoleResearch = new CRMRole("5e17b508-08e4-e811-8100-005056956c1a", "CONBEH", "SURFconextbeheerder"); + CRMRole crmRoleCloud = new CRMRole("cf652619-08e4-e811-8100-005056956c1a", "CONVER", "SURFconextverantwoordelijke"); + CRMContact crmContact = getCrmContact(crmRoleResearch, "guest", "example.com", true); + crmContact.setRoles(List.of(crmRoleCloud, crmRoleResearch)); + //This application is linked to the 'CONBEH' CRM role + String researchEntityId = "https://research"; + super.stubForManageProviderByEntityID(EntityType.SAML20_SP, researchEntityId); + super.stubForManageProviderByEntityID(EntityType.OIDC10_RP, "https://cloud"); + //Ignore the SCIM provisioning + super.stubForManageProvisioning(List.of()); + + String response = given() + .when() + .accept(ContentType.JSON) + .header(API_KEY_HEADER, "secret") + .contentType(ContentType.JSON) + .body(crmContact) + .post("/api/internal/v1/crm") + .then() + .extract() + .asString(); + assertEquals("updated", response); + + stubForManageProviderByEntityID(SAML20_SP, researchEntityId); + List> roles = given() + .when() + .auth().preemptive().basic("aa", "secret") + .accept(ContentType.JSON) + .contentType(ContentType.JSON) + .pathParam("sub", GUEST_SUB) + .queryParam("SPentityID", researchEntityId) + .get("/api/external/v1/aa/{sub}") + .as(new TypeRef<>() { + }); + assertEquals(4, roles.size()); + assertEquals(1, roles.stream().filter(m -> m.containsKey("id")).count()); + List> autorisatie = roles.stream().filter(m -> m.containsKey("autorisatie")).toList(); + assertEquals(3, autorisatie.size()); + List autorizations = autorisatie.stream().map(m -> m.get("autorisatie")).sorted().toList(); + List expected = Stream.of( + "urn:mace:surfnet.nl:surfnet.nl:sab:organizationCode:abbrec", + "urn:mace:surfnet.nl:surfnet.nl:sab:organizationGUID:organisationId", + "urn:mace:surfnet.nl:surfnet.nl:sab:role:SURFconextbeheerder").sorted().toList(); + assertEquals(expected, autorizations); + } } \ No newline at end of file diff --git a/server/src/test/java/invite/crm/CRMControllerTest.java b/server/src/test/java/invite/crm/CRMControllerTest.java new file mode 100644 index 00000000..af842783 --- /dev/null +++ b/server/src/test/java/invite/crm/CRMControllerTest.java @@ -0,0 +1,243 @@ +package invite.crm; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.github.tomakehurst.wiremock.stubbing.ServeEvent; +import invite.AbstractMailTest; +import invite.mail.MimeMessageParser; +import invite.manage.EntityType; +import invite.model.Authority; +import invite.model.RemoteProvisionedGroup; +import invite.model.RemoteProvisionedUser; +import invite.model.Role; +import invite.model.User; +import invite.model.UserRole; +import io.restassured.http.ContentType; +import jakarta.mail.Address; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static com.github.tomakehurst.wiremock.client.WireMock.getAllServeEvents; +import static invite.security.SecurityConfig.API_KEY_HEADER; +import static io.restassured.RestAssured.given; +import static org.junit.jupiter.api.Assertions.*; + +class CRMControllerTest extends AbstractMailTest { + + @Test + void contactProvisioningNewUser() throws JsonProcessingException { + CRMRole crmRole = new CRMRole("roleId", "BVW", "Super"); + CRMContact crmContact = getCrmContact(crmRole, "new_user", "hardewijk.org", true); + //These two applications are linked to the 'BVW' CRM role + super.stubForManageProviderByEntityID(EntityType.OIDC10_RP, "https://calendar"); + super.stubForManageProviderByEntityID(EntityType.SAML20_SP, "https://storage"); + //This will return the SCIM provisioning + super.stubForManageProvisioning(List.of("5")); + //The actual SCIM provisioning + super.stubForCreateScimRole(); + super.stubForCreateScimUser(); + super.stubForUpdateScimRole(); + + String response = given() + .when() + .accept(ContentType.JSON) + .header(API_KEY_HEADER, "secret") + .contentType(ContentType.JSON) + .body(crmContact) + .post("/api/internal/v1/crm") + .then() + .extract() + .asString(); + assertEquals("created", response); + + User user = userRepository.findByCrmContactId("contactId").getFirst(); + assertEquals(1, user.getUserRoles().size()); + + UserRole userRole = user.getUserRoles().iterator().next(); + assertFalse(userRole.isGuestRoleIncluded()); + assertEquals(Authority.GUEST, userRole.getAuthority()); + + Role role = userRole.getRole(); + assertEquals(crmRole.getRoleId(), role.getCrmRoleId()); + assertEquals(crmContact.getOrganisation().getOrganisationId(), role.getCrmOrganisationId()); + + List events = getAllServeEvents().stream().filter(e -> e.getRequest().getUrl().startsWith("/api/scim/v2/")).toList(); + assertEquals(3, events.size()); + } + + @Test + void contactProvisioningExistingUser() throws JsonProcessingException { + CRMRole crmRole = new CRMRole("roleId", "BVW", "Super"); + CRMContact crmContact = getCrmContact(crmRole, "guest", "example.com", true); + //These two applications are linked to the 'BVW' CRM role + super.stubForManageProviderByEntityID(EntityType.OIDC10_RP, "https://calendar"); + super.stubForManageProviderByEntityID(EntityType.SAML20_SP, "https://storage"); + //This will return the SCIM provisioning + super.stubForManageProvisioning(List.of("5")); + //The actual SCIM provisioning - + super.stubForCreateScimUser(); + super.stubForCreateScimRole(); + super.stubForUpdateScimRole(); + //See "scim_user_identifier": "eduID", in src/main/resources/manage/provisioning.json,"_id": "7", + super.stubForProvisionEduID(UUID.randomUUID().toString()); + + User userBefore = userRepository.findBySubIgnoreCase(GUEST_SUB).get(); + assertEquals(3, userBefore.getUserRoles().size()); + + String response = given() + .when() + .accept(ContentType.JSON) + .header(API_KEY_HEADER, "secret") + .contentType(ContentType.JSON) + .body(crmContact) + .post("/api/internal/v1/crm") + .then() + .extract() + .asString(); + assertEquals("updated", response); + + User user = userRepository.findBySubIgnoreCase(GUEST_SUB).get(); + assertEquals(4, user.getUserRoles().size()); + + UserRole userRole = user.getUserRoles().stream().filter(ur -> crmRole.getRoleId().equals(ur.getRole().getCrmRoleId())) + .findFirst().get(); + assertFalse(userRole.isGuestRoleIncluded()); + assertEquals(Authority.GUEST, userRole.getAuthority()); } + + @Test + void contactProvisioningMissingUID() { + CRMRole crmRole = new CRMRole("roleId", "BVW", "Super"); + CRMContact crmContact = getCrmContact(crmRole, "new_user", "hardewijk.org", true); + //This will force the InvalidInputException + crmContact.setUid(""); + + given() + .when() + .accept(ContentType.JSON) + .header(API_KEY_HEADER, "secret") + .contentType(ContentType.JSON) + .body(crmContact) + .post("/api/internal/v1/crm") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()); + } + + @Test + void contactInviteNewUser() throws Exception { + CRMRole crmRole = new CRMRole("roleId", "BVW", "Super"); + CRMContact crmContact = getCrmContact(crmRole, "new_user", "hardewijk.org", false); + //These two applications are linked to the 'BVW' CRM role + super.stubForManageProviderByEntityID(EntityType.OIDC10_RP, "https://calendar"); + super.stubForManageProviderByEntityID(EntityType.SAML20_SP, "https://storage"); + + String response = given() + .when() + .accept(ContentType.JSON) + .header(API_KEY_HEADER, "secret") + .contentType(ContentType.JSON) + .body(crmContact) + .post("/api/internal/v1/crm") + .then() + .extract() + .asString(); + assertEquals("created", response); + + List users = userRepository.findByCrmContactId("contactId"); + assertTrue(users.isEmpty()); + + MimeMessageParser mimeMessageParser = mailMessage(); + List
toAddresses = mimeMessageParser.getTo(); + assertEquals(1, toAddresses.size()); + assertEquals("jdoe@example.com", toAddresses.getFirst().toString()); + assertTrue(mimeMessageParser.getHtmlContent() + .contains("Invitation for Beveiligingsverantwoordelijke for Inc. Corporated at SURFconext Invite")); + } + + @Test + void contactProvisioningRemoveScimRole() throws JsonProcessingException { + CRMRole crmRoleResearch = new CRMRole("5e17b508-08e4-e811-8100-005056956c1a", "CONBEH", "SURFconextbeheerder"); + CRMRole crmRoleCloud = new CRMRole("cf652619-08e4-e811-8100-005056956c1a", "CONVER", "SURFconextverantwoordelijke"); + CRMContact crmContact = getCrmContact(crmRoleResearch, "guest", "example.com", true); + crmContact.setRoles(List.of(crmRoleCloud, crmRoleResearch)); + //This application is linked to the 'CONBEH' CRM role + super.stubForManageProviderByEntityID(EntityType.SAML20_SP, "https://research"); + super.stubForManageProviderByEntityID(EntityType.OIDC10_RP, "https://cloud"); + //Ignore the SCIM provisioning + super.stubForManageProvisioning(List.of()); + + User userPre = userRepository.findBySubIgnoreCase(GUEST_SUB).get(); + assertEquals(3, userPre.getUserRoles().size()); + + String response = given() + .when() + .accept(ContentType.JSON) + .header(API_KEY_HEADER, "secret") + .contentType(ContentType.JSON) + .body(crmContact) + .post("/api/internal/v1/crm") + .then() + .extract() + .asString(); + assertEquals("updated", response); + + User user = userRepository.findBySubIgnoreCase(GUEST_SUB).get(); + assertEquals(5, user.getUserRoles().size()); + + crmContact.setRoles(List.of()); + String newResponse = given() + .when() + .accept(ContentType.JSON) + .header(API_KEY_HEADER, "secret") + .contentType(ContentType.JSON) + .body(crmContact) + .post("/api/internal/v1/crm") + .then() + .extract() + .asString(); + assertEquals("updated", newResponse); + + user = userRepository.findBySubIgnoreCase(GUEST_SUB).get(); + assertEquals(3, user.getUserRoles().size()); + } + + @Test + void deleteUser() throws JsonProcessingException { + CRMContact crmContact = new CRMContact(); + crmContact.setContactId(CRM_CONTACT_ID); + + super.stubForManageProvisioning(List.of("5")); + Role role = roleRepository.findByName("Research").get(); + RemoteProvisionedGroup remoteProvisionedGroup = new RemoteProvisionedGroup(role, UUID.randomUUID().toString(), "7"); + super.remoteProvisionedGroupRepository.save(remoteProvisionedGroup); + + User user = userRepository.findBySubIgnoreCase(KB_USER_SUB).get(); + RemoteProvisionedUser remoteProvisionedUser = new RemoteProvisionedUser(user, UUID.randomUUID().toString(), "7"); + super.remoteProvisionedUserRepository.save(remoteProvisionedUser); + //Because of the PUT request of the change in the group, all users are fetched and checked if they exists in the remote SCIM + User guestUser = userRepository.findBySubIgnoreCase(GUEST_SUB).get(); + RemoteProvisionedUser remoteProvisionedUserGuest = new RemoteProvisionedUser(guestUser, UUID.randomUUID().toString(), "7"); + super.remoteProvisionedUserRepository.save(remoteProvisionedUserGuest); + + super.stubForUpdateScimRole(); + super.stubForDeleteScimUser(); + + String response = given() + .when() + .accept(ContentType.JSON) + .header(API_KEY_HEADER, "secret") + .contentType(ContentType.JSON) + .body(crmContact) + .delete("/api/internal/v1/crm") + .then() + .extract() + .asString(); + assertEquals("deleted", response); + + List users = userRepository.findByCrmContactId("contactId"); + assertTrue(users.isEmpty()); + } + +} \ No newline at end of file diff --git a/server/src/test/resources/crm/delete.json b/server/src/test/resources/crm/delete.json new file mode 100644 index 00000000..f205e0ac --- /dev/null +++ b/server/src/test/resources/crm/delete.json @@ -0,0 +1,10 @@ +{ + "contactId": "5c498033-3fb1-ed11-83ff-000d3aaac4e3", + "organisation": { + "organisationId": "ad93daef-0911-e511-80d0-005056956c1a", + "abbrev": "SURFNET", + "name": "SURFnet bv" + } +} + + diff --git a/server/src/test/resources/crm/post.json b/server/src/test/resources/crm/post.json new file mode 100644 index 00000000..493fdd73 --- /dev/null +++ b/server/src/test/resources/crm/post.json @@ -0,0 +1,30 @@ +{ + "contactId": "a7792096-dd49-eb11-9104-0050569571ea", + "firstname": "Pipo", + "middlename": "de", + "surname": "Clown", + "mobile": "+31622803490", + "email": "henny.bekker@braindrops.org", + "organisation": { + "organisationId": "ad93daef-0911-e511-80d0-005056956c1a", + "abbrev": "SURFnet", + "name": "SURFnet bv" + }, + "roles": [ + { + "roleId": "5e17b508-08e4-e811-8100-005056956c1a", + "sabCode": "CONBEH", + "name": "SURFconextbeheerder" + }, + { + "roleId": "112a6aa1-07e4-e811-8100-005056956c1a", + "sabCode": "OB", + "name": "Operationeel beheerder" + }, + { + "roleId": "4287096a-08e4-e811-8100-005056956c1a", + "sabCode": "SOB", + "name": "SURFopzichter-beheerder" + } + ] +} \ No newline at end of file diff --git a/server/src/test/resources/crm/put.json b/server/src/test/resources/crm/put.json new file mode 100644 index 00000000..530037af --- /dev/null +++ b/server/src/test/resources/crm/put.json @@ -0,0 +1,25 @@ +{ + "contactId": "a7792096-dd49-eb11-9104-0050569571ea", + "firstname": "Pipo", + "middlename": "de", + "surname": "Clown", + "mobile": "+31622803490", + "email": "henny.bekker@braindrops.org", + "organisation": { + "organisationId": "ad93daef-0911-e511-80d0-005056956c1a", + "abbrev": "SURFnet", + "name": "SURFnet bv" + }, + "roles": [ + { + "roleId": "5e17b508-08e4-e811-8100-005056956c1a", + "sabCode": "CONBEH", + "name": "SURFconextbeheerder" + }, + { + "roleId": "112a6aa1-07e4-e811-8100-005056956c1a", + "sabCode": "OB", + "name": "Operationeel beheerder" + } + ] +} \ No newline at end of file diff --git a/welcome/package.json b/welcome/package.json index 721c3bc5..ba7fc38f 100644 --- a/welcome/package.json +++ b/welcome/package.json @@ -5,7 +5,7 @@ "scripts": { "dev": "vite", "build": "vite build", - "lint": "eslint .", + "lint": "eslint ./src", "preview": "vite preview", "test": "vitest" },