From 2633c98dfb0275d2b1aee3daa8f813f2d70a3adc Mon Sep 17 00:00:00 2001 From: Okke Harsta Date: Thu, 19 Feb 2026 16:55:07 +0100 Subject: [PATCH 01/13] WIP for #650 --- .../AttributeAggregatorController.java | 15 +++++++- .../src/main/java/invite/crm/CRMContact.java | 19 ++++++++++ .../main/java/invite/crm/CRMController.java | 37 +++++++++++++++++++ .../main/java/invite/crm/CRMOrganisation.java | 14 +++++++ server/src/main/java/invite/crm/CRMRole.java | 14 +++++++ server/src/main/java/invite/model/Role.java | 12 ++++++ server/src/main/java/invite/model/User.java | 3 ++ .../invite/repository/UserRepository.java | 2 + .../main/resources/crm/crm_org_code_name.json | 35 ++++++++++++++++++ .../migration/V55_0__crm_roles_users.sql | 9 +++++ server/src/test/resources/crm/delete.json | 10 +++++ server/src/test/resources/crm/post.json | 30 +++++++++++++++ server/src/test/resources/crm/put.json | 25 +++++++++++++ 13 files changed, 224 insertions(+), 1 deletion(-) create mode 100644 server/src/main/java/invite/crm/CRMContact.java create mode 100644 server/src/main/java/invite/crm/CRMController.java create mode 100644 server/src/main/java/invite/crm/CRMOrganisation.java create mode 100644 server/src/main/java/invite/crm/CRMRole.java create mode 100644 server/src/main/resources/crm/crm_org_code_name.json create mode 100644 server/src/main/resources/db/mysql/migration/V55_0__crm_roles_users.sql create mode 100644 server/src/test/resources/crm/delete.json create mode 100644 server/src/test/resources/crm/post.json create mode 100644 server/src/test/resources/crm/put.json diff --git a/server/src/main/java/invite/aggregation/AttributeAggregatorController.java b/server/src/main/java/invite/aggregation/AttributeAggregatorController.java index 9b4d73c7..fdcc525b 100644 --- a/server/src/main/java/invite/aggregation/AttributeAggregatorController.java +++ b/server/src/main/java/invite/aggregation/AttributeAggregatorController.java @@ -71,9 +71,22 @@ public ResponseEntity>> getGroupMemberships(@PathVariab user.setLastActivity(Instant.now()); userRepository.save(user); + /* + * Also return "surf-autorisaties": [ + * "urn:mace:surfnet.nl:surfnet.nl:sab:organizationCode:SURFNET", + * "urn:mace:surfnet.nl:surfnet.nl:sab:organizationGUID:ad93daef-0911-e511-80d0-005056956c1a", + * "urn:mace:surfnet.nl:surfnet.nl:sab:role:DNS-Beheerder", + * "urn:mace:surfnet.nl:surfnet.nl:sab:role:Instellingsbevoegde", + * "urn:mace:surfnet.nl:surfnet.nl:sab:role:SURFconextbeheerder", + * "urn:mace:surfnet.nl:surfnet.nl:sab:role:Superuser" + * ], + * Reduce all crmOrganizationId's and crmOrganizationCode's to an unique set, and rebuild the + */ + Map provider = optionalProvider.get(); List> userRoles = user.getUserRoles().stream() - .filter(userRole -> userRole.getRole().applicationsUsed().stream().anyMatch(application -> application.getManageId().equals(provider.get("id")))) + .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(); 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..5e09146b --- /dev/null +++ b/server/src/main/java/invite/crm/CRMContact.java @@ -0,0 +1,19 @@ +package invite.crm; + +import lombok.Getter; +import lombok.Setter; + +import java.util.List; + +@Getter +@Setter +public class CRMContact { + + 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..04f5ce32 --- /dev/null +++ b/server/src/main/java/invite/crm/CRMController.java @@ -0,0 +1,37 @@ +package invite.crm; + +import invite.aggregation.AttributeAggregatorController; +import invite.repository.UserRepository; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +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.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping(value = {"/api/external/v1/crm"}, produces = MediaType.APPLICATION_JSON_VALUE) +public class CRMController { + + private static final Log LOG = LogFactory.getLog(CRMController.class); + + private final UserRepository userRepository; + + public CRMController(UserRepository userRepository) { + this.userRepository = userRepository; + } + + @PostMapping("") + @PreAuthorize("hasRole('CRM')") + public ResponseEntity contact(@RequestBody CRMContact crmContact) { + userRepository.findBySubIgnoreCase() + } 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..c8b8f7ac --- /dev/null +++ b/server/src/main/java/invite/crm/CRMOrganisation.java @@ -0,0 +1,14 @@ +package invite.crm; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +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..09ca404e --- /dev/null +++ b/server/src/main/java/invite/crm/CRMRole.java @@ -0,0 +1,14 @@ +package invite.crm; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class CRMRole { + + private String roleId; + private String sabCode; + private String name; + +} 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..fa84ef3c 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<>(); diff --git a/server/src/main/java/invite/repository/UserRepository.java b/server/src/main/java/invite/repository/UserRepository.java index 0dc9a9ea..b88c86e8 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); + Optional findBySubIgnoreCase(String sub); + List findByOrganizationGUIDAndInstitutionAdmin(String organizationGUID, boolean institutionAdmin); List findBySuperUserTrue(); diff --git a/server/src/main/resources/crm/crm_org_code_name.json b/server/src/main/resources/crm/crm_org_code_name.json new file mode 100644 index 00000000..8182ee89 --- /dev/null +++ b/server/src/main/resources/crm/crm_org_code_name.json @@ -0,0 +1,35 @@ +{ + "AAI": "AAIverantwoordelijke", + "ACS": "Algemeen_Contactpersoon_SURF", + "BEH-CON": "Beheerder-Conext", + "BEH-MAIL": "Beheerder-Mailfilter", + "BEH-RAP": "Beheerder-Rapportage", + "BVW": "Beveiligingsverantwoordelijke", + "CONBEH": "SURFconextbeheerder", + "CONVER": "SURFconextverantwoordelijke", + "CPH": "Contactpersoon Hardware", + "DNS": "DNS-Beheerder", + "DOM": "Domeinnamenverantwoordelijke", + "EDUV": "edubadges-verantwoordelijke", + "IBV": "Instellingsbevoegde", + "INFB": "Infrabeheerder", + "IVW": "Infraverantwoordelijke", + "MAIL": "Mailverantwoordelijke", + "MISP": "MISP-beheerder", + "OB": "OperationeelBeheerder", + "SDB": "SURFdrive-beheerder", + "SDCPRN": "SURFdropjesContacpersoon", + "SDV": "SURFdrive-verantwoordelijke", + "SIEMB": "SIEM-beheerder", + "SIEMV": "SIEM-verantwoordelijke", + "SOB": "SURFopzichter-beheerder", + "SRAM": "SRAM-verantwoordelijke", + "SUP": "Superuser", + "SUPRO": "SuperuserRO", + "SURFCumu": "SURFCumulusverantwoordelijke", + "SWB": "SURFwireless-beheerder", + "SWV": "SURFwireless-verantwoordelijke", + "TPM-CON": "TPM-Conext", + "TPM-MAIL": "TPM-Mailfilter", + "TPM-RAP": "TPM-Rapportage" +} \ No newline at end of file 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..d300c91b --- /dev/null +++ b/server/src/main/resources/db/mysql/migration/V55_0__crm_roles_users.sql @@ -0,0 +1,9 @@ +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; +ALTER TABLE `users` + add `crm_contact_id` varchar(255) DEFAULT NULL; +ALTER TABLE `invitations` + add `crm_contact_id` varchar(255) DEFAULT NULL; 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 From 0066fe77f4f1eaa992614c8f94dc72f524b85193 Mon Sep 17 00:00:00 2001 From: Okke Harsta Date: Thu, 19 Feb 2026 22:04:33 +0100 Subject: [PATCH 02/13] WIP for #650 --- .../main/java/invite/crm/CRMController.java | 11 +++------- .../invite/repository/UserRepository.java | 2 +- .../migration/V55_0__crm_roles_users.sql | 21 +++++++++++++------ 3 files changed, 19 insertions(+), 15 deletions(-) diff --git a/server/src/main/java/invite/crm/CRMController.java b/server/src/main/java/invite/crm/CRMController.java index 04f5ce32..de293f6a 100644 --- a/server/src/main/java/invite/crm/CRMController.java +++ b/server/src/main/java/invite/crm/CRMController.java @@ -1,23 +1,16 @@ package invite.crm; -import invite.aggregation.AttributeAggregatorController; import invite.repository.UserRepository; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; -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.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import java.util.List; -import java.util.Map; - @RestController @RequestMapping(value = {"/api/external/v1/crm"}, produces = MediaType.APPLICATION_JSON_VALUE) public class CRMController { @@ -33,5 +26,7 @@ public CRMController(UserRepository userRepository) { @PostMapping("") @PreAuthorize("hasRole('CRM')") public ResponseEntity contact(@RequestBody CRMContact crmContact) { - userRepository.findBySubIgnoreCase() +// userRepository.findBySubIgnoreCase() + return ResponseEntity.ok().body("created"); } +} diff --git a/server/src/main/java/invite/repository/UserRepository.java b/server/src/main/java/invite/repository/UserRepository.java index b88c86e8..c6dddefb 100644 --- a/server/src/main/java/invite/repository/UserRepository.java +++ b/server/src/main/java/invite/repository/UserRepository.java @@ -19,7 +19,7 @@ public interface UserRepository extends JpaRepository, QueryRewriter Optional findBySubIgnoreCase(String sub); - Optional findBySubIgnoreCase(String sub); + Optional findByCrmContactId(String crmContactId); List findByOrganizationGUIDAndInstitutionAdmin(String organizationGUID, boolean institutionAdmin); 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 index d300c91b..98ea26f3 100644 --- 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 @@ -1,9 +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; + 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; + 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; + 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 From 4630c274f0f3567a0c42a376a6732d98c5e60e0d Mon Sep 17 00:00:00 2001 From: Okke Harsta Date: Fri, 20 Feb 2026 11:43:07 +0100 Subject: [PATCH 03/13] WIP for #650 --- .../src/main/java/invite/crm/CRMContact.java | 6 +++ .../main/java/invite/crm/CRMController.java | 21 +++++++-- .../main/java/invite/crm/CRMOrganisation.java | 6 +++ server/src/main/java/invite/crm/CRMRole.java | 6 +++ .../java/invite/security/SecurityConfig.java | 30 ++++++++++++- server/src/main/resources/application.yml | 3 ++ .../java/invite/crm/CRMControllerTest.java | 45 +++++++++++++++++++ 7 files changed, 112 insertions(+), 5 deletions(-) create mode 100644 server/src/test/java/invite/crm/CRMControllerTest.java diff --git a/server/src/main/java/invite/crm/CRMContact.java b/server/src/main/java/invite/crm/CRMContact.java index 5e09146b..f2a4d792 100644 --- a/server/src/main/java/invite/crm/CRMContact.java +++ b/server/src/main/java/invite/crm/CRMContact.java @@ -1,12 +1,18 @@ 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 contactId; diff --git a/server/src/main/java/invite/crm/CRMController.java b/server/src/main/java/invite/crm/CRMController.java index de293f6a..309c7686 100644 --- a/server/src/main/java/invite/crm/CRMController.java +++ b/server/src/main/java/invite/crm/CRMController.java @@ -1,5 +1,6 @@ package invite.crm; +import invite.model.User; import invite.repository.UserRepository; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; @@ -11,8 +12,11 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; + @RestController -@RequestMapping(value = {"/api/external/v1/crm"}, produces = MediaType.APPLICATION_JSON_VALUE) +@RequestMapping(value = {"/api/internal/v1/crm"}, produces = MediaType.APPLICATION_JSON_VALUE) public class CRMController { private static final Log LOG = LogFactory.getLog(CRMController.class); @@ -24,9 +28,18 @@ public CRMController(UserRepository userRepository) { } @PostMapping("") - @PreAuthorize("hasRole('CRM')") public ResponseEntity contact(@RequestBody CRMContact crmContact) { -// userRepository.findBySubIgnoreCase() - return ResponseEntity.ok().body("created"); + LOG.debug("POST /api/external/v1/crm: " + crmContact); + + AtomicReference response = new AtomicReference<>(); + Optional userOptional = userRepository.findByCrmContactId(crmContact.getContactId()); + userOptional.ifPresentOrElse(user -> { + response.set("updated"); + }, + () -> { + response.set("created"); + }); + + return ResponseEntity.ok().body(response.get()); } } diff --git a/server/src/main/java/invite/crm/CRMOrganisation.java b/server/src/main/java/invite/crm/CRMOrganisation.java index c8b8f7ac..92ce341a 100644 --- a/server/src/main/java/invite/crm/CRMOrganisation.java +++ b/server/src/main/java/invite/crm/CRMOrganisation.java @@ -1,10 +1,16 @@ 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; diff --git a/server/src/main/java/invite/crm/CRMRole.java b/server/src/main/java/invite/crm/CRMRole.java index 09ca404e..1683ee9e 100644 --- a/server/src/main/java/invite/crm/CRMRole.java +++ b/server/src/main/java/invite/crm/CRMRole.java @@ -1,10 +1,16 @@ 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; 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..8b5064e3 100644 --- a/server/src/main/resources/application.yml +++ b/server/src/main/resources/application.yml @@ -100,6 +100,9 @@ oidcng: resource-server-secret: secret base-url: http://localhost:8888 +crm: + api-key-header: secret + super-admin: users: - "urn:collab:person:example.com:admin" 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..44deef43 --- /dev/null +++ b/server/src/test/java/invite/crm/CRMControllerTest.java @@ -0,0 +1,45 @@ +package invite.crm; + +import invite.AbstractTest; +import invite.model.InvitationResponse; +import io.restassured.common.mapper.TypeRef; +import io.restassured.http.ContentType; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static invite.security.SecurityConfig.API_KEY_HEADER; +import static invite.security.SecurityConfig.API_TOKEN_HEADER; +import static io.restassured.RestAssured.given; +import static org.junit.jupiter.api.Assertions.*; + +class CRMControllerTest extends AbstractTest { + + @Test + void contact() { + CRMContact crmContact = new CRMContact( + "contactId", + "John", + "from", + "Doe", + "jdoe@example.com", + new CRMOrganisation( + "organisationId", + "abbrec", + "Inc. Corporated" + ), + List.of(new CRMRole("roleId","sabCode","Super")) + ); + 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); + } +} \ No newline at end of file From cc191c475fb2a1bd3d64b4c23ec582d0b021e5b2 Mon Sep 17 00:00:00 2001 From: Okke Harsta Date: Sat, 21 Feb 2026 15:54:23 +0100 Subject: [PATCH 04/13] BSR --- .../AttributeAggregatorController.java | 49 ++++++++++++------- .../src/main/java/invite/crm/CRMContact.java | 3 ++ .../main/java/invite/crm/CRMController.java | 40 ++++++++++++++- .../java/invite/provision/scim/GroupURN.java | 8 ++- .../invite/repository/RoleRepository.java | 2 + server/src/main/resources/application.yml | 1 + .../java/invite/crm/CRMControllerTest.java | 3 ++ 7 files changed, 86 insertions(+), 20 deletions(-) diff --git a/server/src/main/java/invite/aggregation/AttributeAggregatorController.java b/server/src/main/java/invite/aggregation/AttributeAggregatorController.java index fdcc525b..dee3e967 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; @@ -71,33 +80,39 @@ public ResponseEntity>> getGroupMemberships(@PathVariab user.setLastActivity(Instant.now()); userRepository.save(user); - /* - * Also return "surf-autorisaties": [ - * "urn:mace:surfnet.nl:surfnet.nl:sab:organizationCode:SURFNET", - * "urn:mace:surfnet.nl:surfnet.nl:sab:organizationGUID:ad93daef-0911-e511-80d0-005056956c1a", - * "urn:mace:surfnet.nl:surfnet.nl:sab:role:DNS-Beheerder", - * "urn:mace:surfnet.nl:surfnet.nl:sab:role:Instellingsbevoegde", - * "urn:mace:surfnet.nl:surfnet.nl:sab:role:SURFconextbeheerder", - * "urn:mace:surfnet.nl:surfnet.nl:sab:role:Superuser" - * ], - * Reduce all crmOrganizationId's and crmOrganizationCode's to an unique set, and rebuild the - */ - Map provider = optionalProvider.get(); - List> userRoles = user.getUserRoles().stream() + 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) + .collect(Collectors.toCollection(ArrayList::new)); + + List crmRoles = user.getUserRoles().stream() + .filter(userRole -> StringUtils.hasText(userRole.getRole().getCrmRoleId())) + .map(userRole -> userRole.getRole()) .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); + Set uniqueOrganizationCodes = crmRoles.stream() + .map(role -> "urn:mace:surfnet.nl:surfnet.nl:sab:organizationCode:" + role.getCrmOrganisationCode()) + .collect(Collectors.toSet()); + Set uniqueOrganizationGUIDS = crmRoles.stream() + .map(role -> "urn:mace:surfnet.nl:surfnet.nl:sab:organizationGUID:" + role.getCrmOrganisationId()) + .collect(Collectors.toSet()); + uniqueOrganizationCodes.addAll(uniqueOrganizationGUIDS); + List> organisationRoles = uniqueOrganizationCodes.stream() + .map(role -> Map.of("autorisatie", role)) + .toList(); + userRoleList.addAll(organisationRoles); + + 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/crm/CRMContact.java b/server/src/main/java/invite/crm/CRMContact.java index f2a4d792..714648f7 100644 --- a/server/src/main/java/invite/crm/CRMContact.java +++ b/server/src/main/java/invite/crm/CRMContact.java @@ -15,6 +15,9 @@ @AllArgsConstructor public class CRMContact { + private String uid; + private String schacHome; + private boolean sendInvitation; private String contactId; private String firstname; private String middlename; diff --git a/server/src/main/java/invite/crm/CRMController.java b/server/src/main/java/invite/crm/CRMController.java index 309c7686..abbc1c52 100644 --- a/server/src/main/java/invite/crm/CRMController.java +++ b/server/src/main/java/invite/crm/CRMController.java @@ -1,17 +1,22 @@ package invite.crm; +import invite.model.Role; import invite.model.User; +import invite.provision.ProvisioningService; +import invite.repository.RoleRepository; import invite.repository.UserRepository; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; +import org.springframework.beans.factory.annotation.Value; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; -import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.util.StringUtils; 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.util.List; import java.util.Optional; import java.util.concurrent.atomic.AtomicReference; @@ -21,10 +26,19 @@ 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 ProvisioningService provisioningService; - public CRMController(UserRepository userRepository) { + public CRMController(@Value("${crm.collab-person-prefix}") String collabPersonPrefix, + UserRepository userRepository, + RoleRepository roleRepository, + ProvisioningService provisioningService) { this.userRepository = userRepository; + this.collabPersonPrefix = collabPersonPrefix; + this.roleRepository = roleRepository; + this.provisioningService = provisioningService; } @PostMapping("") @@ -34,12 +48,34 @@ public ResponseEntity contact(@RequestBody CRMContact crmContact) { AtomicReference response = new AtomicReference<>(); Optional userOptional = userRepository.findByCrmContactId(crmContact.getContactId()); userOptional.ifPresentOrElse(user -> { + List currentRoles = user.getUserRoles().stream() + .map(userRole -> userRole.getRole()) + .toList(); + List newCrmRoles = crmContact.getRoles().stream() + .filter(crmRole -> currentRoles.stream() + .noneMatch(role -> role.getCrmRoleId().equalsIgnoreCase(crmRole.getRoleId()))) + .toList(); + //Ensure not to delete regular non-CRM roles + List deletedRoles = currentRoles.stream() + .filter(role -> StringUtils.hasText(role.getCrmRoleId()) && + crmContact.getRoles().stream() + .noneMatch(crmRole -> crmRole.getRoleId().equalsIgnoreCase(role.getCrmRoleId()))) + .toList(); + newCrmRoles.forEach(crmRole -> { + roleRepository.findByCrmRoleId(crmRole.getRoleId()) + .ifPresentOrElse(); + }); response.set("updated"); }, () -> { + //Based on the crmContact. response.set("created"); }); return ResponseEntity.ok().body(response.get()); } + + private Role createRole(CRMOrganisation crmOrganisation, CRMRole crmRole) { + Role role = new Role(); + } } 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/resources/application.yml b/server/src/main/resources/application.yml index 8b5064e3..dbed6d8e 100644 --- a/server/src/main/resources/application.yml +++ b/server/src/main/resources/application.yml @@ -102,6 +102,7 @@ oidcng: crm: api-key-header: secret + collab-person-prefix: "urn:collab:person:" super-admin: users: diff --git a/server/src/test/java/invite/crm/CRMControllerTest.java b/server/src/test/java/invite/crm/CRMControllerTest.java index 44deef43..5d8026a7 100644 --- a/server/src/test/java/invite/crm/CRMControllerTest.java +++ b/server/src/test/java/invite/crm/CRMControllerTest.java @@ -18,6 +18,9 @@ class CRMControllerTest extends AbstractTest { @Test void contact() { CRMContact crmContact = new CRMContact( + "new_user", + "hardewijk.org", + false, "contactId", "John", "from", From 57fe30d45c4627ab549cf33885ad36c364d0d256 Mon Sep 17 00:00:00 2001 From: Okke Harsta Date: Mon, 23 Feb 2026 15:02:38 +0100 Subject: [PATCH 05/13] WIP for #650 --- .../java/invite/api/InvitationController.java | 48 +++- .../java/invite/api/InvitationOperations.java | 9 +- .../src/main/java/invite/crm/CRMContact.java | 4 +- .../main/java/invite/crm/CRMController.java | 254 ++++++++++++++++-- .../main/java/invite/crm/CrmConfigEntry.java | 8 + .../java/invite/crm/CrmManageIdentifier.java | 8 + ...itationUniqueCrmOrganisationException.java | 13 + server/src/main/java/invite/mail/MailBox.java | 2 +- .../main/java/invite/model/Invitation.java | 3 + server/src/main/java/invite/model/User.java | 26 +- .../provision/ProvisioningServiceDefault.java | 5 +- .../invite/repository/UserRepository.java | 2 +- server/src/main/resources/application.yml | 5 +- server/src/main/resources/crm/crm_config.json | 50 ++++ .../main/resources/crm/crm_org_code_name.json | 35 --- 15 files changed, 379 insertions(+), 93 deletions(-) create mode 100644 server/src/main/java/invite/crm/CrmConfigEntry.java create mode 100644 server/src/main/java/invite/crm/CrmManageIdentifier.java create mode 100644 server/src/main/java/invite/exception/InvitationUniqueCrmOrganisationException.java create mode 100644 server/src/main/resources/crm/crm_config.json delete mode 100644 server/src/main/resources/crm/crm_org_code_name.json diff --git a/server/src/main/java/invite/api/InvitationController.java b/server/src/main/java/invite/api/InvitationController.java index e2904f89..cf6f2e23 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,19 @@ 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()), + invitation.getRoles().stream().map(invitationRole -> invitationRole.getRole().getCrmOrganisationId()))); + } + } } 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 index 714648f7..699dd2a3 100644 --- a/server/src/main/java/invite/crm/CRMContact.java +++ b/server/src/main/java/invite/crm/CRMContact.java @@ -16,8 +16,8 @@ public class CRMContact { private String uid; - private String schacHome; - private boolean sendInvitation; + private String schacHomeOrganisation; + private boolean suppressInvitation; private String contactId; private String firstname; private String middlename; diff --git a/server/src/main/java/invite/crm/CRMController.java b/server/src/main/java/invite/crm/CRMController.java index abbc1c52..0b070d19 100644 --- a/server/src/main/java/invite/crm/CRMController.java +++ b/server/src/main/java/invite/crm/CRMController.java @@ -1,24 +1,53 @@ 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.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.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.concurrent.atomic.AtomicReference; +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) @@ -29,53 +58,220 @@ public class CRMController { 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, - ProvisioningService provisioningService) { + 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); - AtomicReference response = new AtomicReference<>(); - Optional userOptional = userRepository.findByCrmContactId(crmContact.getContactId()); - userOptional.ifPresentOrElse(user -> { - List currentRoles = user.getUserRoles().stream() - .map(userRole -> userRole.getRole()) - .toList(); - List newCrmRoles = crmContact.getRoles().stream() - .filter(crmRole -> currentRoles.stream() - .noneMatch(role -> role.getCrmRoleId().equalsIgnoreCase(crmRole.getRoleId()))) - .toList(); - //Ensure not to delete regular non-CRM roles - List deletedRoles = currentRoles.stream() - .filter(role -> StringUtils.hasText(role.getCrmRoleId()) && - crmContact.getRoles().stream() - .noneMatch(crmRole -> crmRole.getRoleId().equalsIgnoreCase(role.getCrmRoleId()))) - .toList(); - newCrmRoles.forEach(crmRole -> { - roleRepository.findByCrmRoleId(crmRole.getRoleId()) - .ifPresentOrElse(); - }); - response.set("updated"); - }, - () -> { - //Based on the crmContact. - response.set("created"); + 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.orElse(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); + 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(); + } - return ResponseEntity.ok().body(response.get()); + private @NonNull List convertCrmRolesToInviteRoles(CRMContact crmContact, List newCrmRoles) { + return newCrmRoles.stream() + .map(crmRole -> roleRepository.findByCrmRoleId(crmRole.getRoleId()) + .orElse(this.createRole(crmContact.getOrganisation(), crmRole))) + .toList(); } private Role createRole(CRMOrganisation crmOrganisation, CRMRole crmRole) { - Role role = new Role(); + CrmConfigEntry crmConfigEntry = this.crmConfig.get(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/CrmConfigEntry.java b/server/src/main/java/invite/crm/CrmConfigEntry.java new file mode 100644 index 00000000..c0314a9d --- /dev/null +++ b/server/src/main/java/invite/crm/CrmConfigEntry.java @@ -0,0 +1,8 @@ +package invite.crm; + +import invite.manage.ManageIdentifier; + +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/User.java b/server/src/main/java/invite/model/User.java index fa84ef3c..a1d4f2ad 100644 --- a/server/src/main/java/invite/model/User.java +++ b/server/src/main/java/invite/model/User.java @@ -143,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"); @@ -173,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 f69df3d7..cf44f5a3 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/repository/UserRepository.java b/server/src/main/java/invite/repository/UserRepository.java index c6dddefb..5ff0a9a1 100644 --- a/server/src/main/java/invite/repository/UserRepository.java +++ b/server/src/main/java/invite/repository/UserRepository.java @@ -19,7 +19,7 @@ public interface UserRepository extends JpaRepository, QueryRewriter Optional findBySubIgnoreCase(String sub); - Optional findByCrmContactId(String crmContactId); + List findByCrmContactId(String crmContactId); List findByOrganizationGUIDAndInstitutionAdmin(String organizationGUID, boolean institutionAdmin); diff --git a/server/src/main/resources/application.yml b/server/src/main/resources/application.yml index dbed6d8e..28caff4f 100644 --- a/server/src/main/resources/application.yml +++ b/server/src/main/resources/application.yml @@ -102,7 +102,10 @@ oidcng: crm: api-key-header: secret - collab-person-prefix: "urn:collab:person:" + 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: 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..3d8c700d --- /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://research", + "manageType": "saml20_sp" + } + ] + }, + "CONBEH": { + "name": "SURFconextbeheerder", + "roleId": "5e17b508-08e4-e811-8100-005056956c1a", + "applications": [ + { + "manageEntityId": "https://calendar", + "manageType": "oidc10_rp" + } + ] + }, + "CONVER": { + "name": "SURFconextverantwoordelijke", + "roleId": "cf652619-08e4-e811-8100-005056956c1a", + "applications": [ + { + "manageEntityId": "https://cloud", + "manageType": "oidc10_rp" + } + ] + } +} diff --git a/server/src/main/resources/crm/crm_org_code_name.json b/server/src/main/resources/crm/crm_org_code_name.json deleted file mode 100644 index 8182ee89..00000000 --- a/server/src/main/resources/crm/crm_org_code_name.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "AAI": "AAIverantwoordelijke", - "ACS": "Algemeen_Contactpersoon_SURF", - "BEH-CON": "Beheerder-Conext", - "BEH-MAIL": "Beheerder-Mailfilter", - "BEH-RAP": "Beheerder-Rapportage", - "BVW": "Beveiligingsverantwoordelijke", - "CONBEH": "SURFconextbeheerder", - "CONVER": "SURFconextverantwoordelijke", - "CPH": "Contactpersoon Hardware", - "DNS": "DNS-Beheerder", - "DOM": "Domeinnamenverantwoordelijke", - "EDUV": "edubadges-verantwoordelijke", - "IBV": "Instellingsbevoegde", - "INFB": "Infrabeheerder", - "IVW": "Infraverantwoordelijke", - "MAIL": "Mailverantwoordelijke", - "MISP": "MISP-beheerder", - "OB": "OperationeelBeheerder", - "SDB": "SURFdrive-beheerder", - "SDCPRN": "SURFdropjesContacpersoon", - "SDV": "SURFdrive-verantwoordelijke", - "SIEMB": "SIEM-beheerder", - "SIEMV": "SIEM-verantwoordelijke", - "SOB": "SURFopzichter-beheerder", - "SRAM": "SRAM-verantwoordelijke", - "SUP": "Superuser", - "SUPRO": "SuperuserRO", - "SURFCumu": "SURFCumulusverantwoordelijke", - "SWB": "SURFwireless-beheerder", - "SWV": "SURFwireless-verantwoordelijke", - "TPM-CON": "TPM-Conext", - "TPM-MAIL": "TPM-Mailfilter", - "TPM-RAP": "TPM-Rapportage" -} \ No newline at end of file From 56d0b31db64c1707dc93f0ee9cb443890f328ea5 Mon Sep 17 00:00:00 2001 From: Okke Harsta Date: Mon, 23 Feb 2026 17:02:33 +0100 Subject: [PATCH 06/13] WIP for #650 --- .../main/java/invite/crm/CRMController.java | 4 +- server/src/main/resources/crm/crm_config.json | 8 +- .../test/java/invite/AbstractMailTest.java | 5 +- .../java/invite/crm/CRMControllerTest.java | 171 +++++++++++++++--- 4 files changed, 158 insertions(+), 30 deletions(-) diff --git a/server/src/main/java/invite/crm/CRMController.java b/server/src/main/java/invite/crm/CRMController.java index 0b070d19..0bd78304 100644 --- a/server/src/main/java/invite/crm/CRMController.java +++ b/server/src/main/java/invite/crm/CRMController.java @@ -137,7 +137,7 @@ public ResponseEntity delete(@RequestBody CRMContact crmContact) { private boolean provisionUser(CRMContact crmContact) { String sub = constructSub(crmContact); Optional optionalUser = userRepository.findBySubIgnoreCase(sub); - User user = optionalUser.orElse(createUser(crmContact, sub)); + User user = optionalUser.orElseGet(() -> createUser(crmContact, sub)); List newCrmRoles = syncCrmRoles(crmContact, user); List roles = convertCrmRolesToInviteRoles(crmContact, newCrmRoles); @@ -219,7 +219,7 @@ private boolean sendInvitation(CRMContact crmContact) { private @NonNull List convertCrmRolesToInviteRoles(CRMContact crmContact, List newCrmRoles) { return newCrmRoles.stream() .map(crmRole -> roleRepository.findByCrmRoleId(crmRole.getRoleId()) - .orElse(this.createRole(crmContact.getOrganisation(), crmRole))) + .orElseGet(() -> this.createRole(crmContact.getOrganisation(), crmRole))) .toList(); } diff --git a/server/src/main/resources/crm/crm_config.json b/server/src/main/resources/crm/crm_config.json index 3d8c700d..136458d1 100644 --- a/server/src/main/resources/crm/crm_config.json +++ b/server/src/main/resources/crm/crm_config.json @@ -22,8 +22,8 @@ "manageType": "saml20_sp" }, { - "manageEntityId": "https://research", - "manageType": "saml20_sp" + "manageEntityId": "https://calendar", + "manageType": "oidc10_rp" } ] }, @@ -32,8 +32,8 @@ "roleId": "5e17b508-08e4-e811-8100-005056956c1a", "applications": [ { - "manageEntityId": "https://calendar", - "manageType": "oidc10_rp" + "manageEntityId": "https://research", + "manageType": "saml20_sp" } ] }, 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/crm/CRMControllerTest.java b/server/src/test/java/invite/crm/CRMControllerTest.java index 5d8026a7..dd92df3e 100644 --- a/server/src/test/java/invite/crm/CRMControllerTest.java +++ b/server/src/test/java/invite/crm/CRMControllerTest.java @@ -1,38 +1,136 @@ package invite.crm; -import invite.AbstractTest; -import invite.model.InvitationResponse; -import io.restassured.common.mapper.TypeRef; +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.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.List; +import java.util.UUID; +import static com.github.tomakehurst.wiremock.client.WireMock.getAllServeEvents; import static invite.security.SecurityConfig.API_KEY_HEADER; -import static invite.security.SecurityConfig.API_TOKEN_HEADER; import static io.restassured.RestAssured.given; import static org.junit.jupiter.api.Assertions.*; -class CRMControllerTest extends AbstractTest { +class CRMControllerTest extends AbstractMailTest { @Test - void contact() { - CRMContact crmContact = new CRMContact( - "new_user", - "hardewijk.org", - false, - "contactId", - "John", - "from", - "Doe", - "jdoe@example.com", - new CRMOrganisation( - "organisationId", - "abbrec", - "Inc. Corporated" - ), - List.of(new CRMRole("roleId","sabCode","Super")) - ); + 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("urn:collab:person:example.com:guest").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("urn:collab:person:example.com:guest").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() throws JsonProcessingException { + 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) @@ -44,5 +142,34 @@ void contact() { .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")); + } + + private 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) + ); } } \ No newline at end of file From 2312061b76f260db04300ac397d113581cf4be12 Mon Sep 17 00:00:00 2001 From: Okke Harsta Date: Mon, 23 Feb 2026 20:33:13 +0100 Subject: [PATCH 07/13] Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- server/src/main/java/invite/api/InvitationController.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/server/src/main/java/invite/api/InvitationController.java b/server/src/main/java/invite/api/InvitationController.java index cf6f2e23..ac8ec23b 100644 --- a/server/src/main/java/invite/api/InvitationController.java +++ b/server/src/main/java/invite/api/InvitationController.java @@ -562,8 +562,12 @@ private void checkCrmUniqueOrganisation(User user, Invitation invitation) { 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()), - invitation.getRoles().stream().map(invitationRole -> invitationRole.getRole().getCrmOrganisationId()))); + user.getUserRoles().stream() + .map(userRole -> userRole.getRole().getCrmOrganisationId()) + .toList(), + invitation.getRoles().stream() + .map(invitationRole -> invitationRole.getRole().getCrmOrganisationId()) + .toList())); } } From 093faac26a02291e87a84f181deb844002655202 Mon Sep 17 00:00:00 2001 From: Okke Harsta Date: Mon, 23 Feb 2026 20:33:36 +0100 Subject: [PATCH 08/13] Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- server/src/main/java/invite/crm/CrmConfigEntry.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/server/src/main/java/invite/crm/CrmConfigEntry.java b/server/src/main/java/invite/crm/CrmConfigEntry.java index c0314a9d..ec79cc4c 100644 --- a/server/src/main/java/invite/crm/CrmConfigEntry.java +++ b/server/src/main/java/invite/crm/CrmConfigEntry.java @@ -1,7 +1,5 @@ package invite.crm; -import invite.manage.ManageIdentifier; - import java.util.List; public record CrmConfigEntry(String code, String name, List crmManageIdentifiers) { From 296b735741d0333b70917ce8a75b12e2db161eb1 Mon Sep 17 00:00:00 2001 From: Okke Harsta Date: Mon, 23 Feb 2026 20:34:53 +0100 Subject: [PATCH 09/13] Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- server/src/main/java/invite/crm/CRMController.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/server/src/main/java/invite/crm/CRMController.java b/server/src/main/java/invite/crm/CRMController.java index 0bd78304..1e9141bd 100644 --- a/server/src/main/java/invite/crm/CRMController.java +++ b/server/src/main/java/invite/crm/CRMController.java @@ -225,6 +225,9 @@ private boolean sendInvitation(CRMContact crmContact) { 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()) From d07aca4f1184c09438796d72b1877810c93fa297 Mon Sep 17 00:00:00 2001 From: Okke Harsta Date: Tue, 24 Feb 2026 06:32:57 +0100 Subject: [PATCH 10/13] WIP for #650 --- .../java/invite/aggregation/AttributeAggregatorController.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/src/main/java/invite/aggregation/AttributeAggregatorController.java b/server/src/main/java/invite/aggregation/AttributeAggregatorController.java index dee3e967..a207b1db 100644 --- a/server/src/main/java/invite/aggregation/AttributeAggregatorController.java +++ b/server/src/main/java/invite/aggregation/AttributeAggregatorController.java @@ -89,9 +89,10 @@ public ResponseEntity>> getGroupMemberships(@PathVariab .collect(Collectors.toCollection(ArrayList::new)); List crmRoles = user.getUserRoles().stream() - .filter(userRole -> StringUtils.hasText(userRole.getRole().getCrmRoleId())) .map(userRole -> userRole.getRole()) + .filter(role -> StringUtils.hasText(role.getCrmRoleId())) .toList(); + //If there are any CRM roles we need to add the organisation information regardless of the spEntityId Set uniqueOrganizationCodes = crmRoles.stream() .map(role -> "urn:mace:surfnet.nl:surfnet.nl:sab:organizationCode:" + role.getCrmOrganisationCode()) .collect(Collectors.toSet()); From 2588280fd903b5ddbf085776305e1ded9e7071ba Mon Sep 17 00:00:00 2001 From: Okke Harsta Date: Tue, 24 Feb 2026 09:02:03 +0100 Subject: [PATCH 11/13] Fixes #654 --- client/src/pages/RoleForm.jsx | 1 + 1 file changed, 1 insertion(+) 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)} From b0666ea022a4983b834ee1ff5c4de8796eaf9875 Mon Sep 17 00:00:00 2001 From: Okke Harsta Date: Tue, 24 Feb 2026 11:33:43 +0100 Subject: [PATCH 12/13] WIP for #650 --- .../AttributeAggregatorController.java | 33 +++--- .../main/java/invite/crm/CRMController.java | 6 +- server/src/test/java/invite/AbstractTest.java | 25 ++++ .../AttributeAggregatorControllerTest.java | 54 ++++++++- .../java/invite/crm/CRMControllerTest.java | 112 ++++++++++++++---- 5 files changed, 187 insertions(+), 43 deletions(-) diff --git a/server/src/main/java/invite/aggregation/AttributeAggregatorController.java b/server/src/main/java/invite/aggregation/AttributeAggregatorController.java index a207b1db..12dcda4a 100644 --- a/server/src/main/java/invite/aggregation/AttributeAggregatorController.java +++ b/server/src/main/java/invite/aggregation/AttributeAggregatorController.java @@ -40,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; @@ -83,28 +85,21 @@ public ResponseEntity>> getGroupMemberships(@PathVariab Map provider = optionalProvider.get(); List> userRoleList = user.getUserRoles().stream() .filter(userRole -> userRole.getRole().applicationsUsed().stream() - .anyMatch(application -> application.getManageId().equals(provider.get("id")))) + .anyMatch(application -> application.getManageId().equals(provider.get(ID)))) .filter(userRole -> userRole.getAuthority().equals(Authority.GUEST) || userRole.isGuestRoleIncluded()) .map(this::parseUserRole) .collect(Collectors.toCollection(ArrayList::new)); - List crmRoles = user.getUserRoles().stream() - .map(userRole -> userRole.getRole()) - .filter(role -> StringUtils.hasText(role.getCrmRoleId())) - .toList(); - //If there are any CRM roles we need to add the organisation information regardless of the spEntityId - Set uniqueOrganizationCodes = crmRoles.stream() - .map(role -> "urn:mace:surfnet.nl:surfnet.nl:sab:organizationCode:" + role.getCrmOrganisationCode()) - .collect(Collectors.toSet()); - Set uniqueOrganizationGUIDS = crmRoles.stream() - .map(role -> "urn:mace:surfnet.nl:surfnet.nl:sab:organizationGUID:" + role.getCrmOrganisationId()) - .collect(Collectors.toSet()); - uniqueOrganizationCodes.addAll(uniqueOrganizationGUIDS); - List> organisationRoles = uniqueOrganizationCodes.stream() - .map(role -> Map.of("autorisatie", role)) - .toList(); - userRoleList.addAll(organisationRoles); - + 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); @@ -113,7 +108,7 @@ public ResponseEntity>> getGroupMemberships(@PathVariab private Map parseUserRole(UserRole userRole) { Role role = userRole.getRole(); String urn = GroupURN.urnFromRole(groupUrnPrefix, role); - return Map.of(StringUtils.hasText(role.getCrmRoleId()) ? "autorisatie" : "id", urn); + return Map.of(StringUtils.hasText(role.getCrmRoleId()) ? AUTORISATIE : ID, urn); } } diff --git a/server/src/main/java/invite/crm/CRMController.java b/server/src/main/java/invite/crm/CRMController.java index 1e9141bd..7919daf7 100644 --- a/server/src/main/java/invite/crm/CRMController.java +++ b/server/src/main/java/invite/crm/CRMController.java @@ -20,6 +20,7 @@ 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; @@ -32,6 +33,7 @@ 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; @@ -51,6 +53,7 @@ @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); @@ -126,7 +129,6 @@ public ResponseEntity delete(@RequestBody CRMContact 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); }); @@ -145,6 +147,8 @@ private boolean provisionUser(CRMContact crmContact) { .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); 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 index dd92df3e..af842783 100644 --- a/server/src/test/java/invite/crm/CRMControllerTest.java +++ b/server/src/test/java/invite/crm/CRMControllerTest.java @@ -6,6 +6,8 @@ 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; @@ -14,6 +16,7 @@ import org.junit.jupiter.api.Test; import org.springframework.http.HttpStatus; +import java.util.ArrayList; import java.util.List; import java.util.UUID; @@ -81,7 +84,7 @@ void contactProvisioningExistingUser() throws JsonProcessingException { //See "scim_user_identifier": "eduID", in src/main/resources/manage/provisioning.json,"_id": "7", super.stubForProvisionEduID(UUID.randomUUID().toString()); - User userBefore = userRepository.findBySubIgnoreCase("urn:collab:person:example.com:guest").get(); + User userBefore = userRepository.findBySubIgnoreCase(GUEST_SUB).get(); assertEquals(3, userBefore.getUserRoles().size()); String response = given() @@ -96,17 +99,16 @@ void contactProvisioningExistingUser() throws JsonProcessingException { .asString(); assertEquals("updated", response); - User user = userRepository.findBySubIgnoreCase("urn:collab:person:example.com:guest").get(); + 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()); - } + assertEquals(Authority.GUEST, userRole.getAuthority()); } @Test - void contactProvisioningMissingUID() throws JsonProcessingException { + void contactProvisioningMissingUID() { CRMRole crmRole = new CRMRole("roleId", "BVW", "Super"); CRMContact crmContact = getCrmContact(crmRole, "new_user", "hardewijk.org", true); //This will force the InvalidInputException @@ -154,22 +156,88 @@ void contactInviteNewUser() throws Exception { .contains("Invitation for Beveiligingsverantwoordelijke for Inc. Corporated at SURFconext Invite")); } - private 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) - ); + @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 From 8e2b7695f185e722aaba7c5e7bad21c7463a6ece Mon Sep 17 00:00:00 2001 From: Okke Harsta Date: Tue, 24 Feb 2026 11:38:25 +0100 Subject: [PATCH 13/13] Fixed client build --- client/package.json | 2 +- client/src/tabs/Tokens.jsx | 2 +- welcome/package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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/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/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" },