diff --git a/.gitignore b/.gitignore index 4c9be920..716ce970 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,4 @@ NOTES.txt private_key_pkcs8.pem JSON.md spieldata +teams-api-calls.md diff --git a/server/src/main/java/access/api/InvitationController.java b/server/src/main/java/access/api/InvitationController.java index 7ffdd213..3efb74a3 100644 --- a/server/src/main/java/access/api/InvitationController.java +++ b/server/src/main/java/access/api/InvitationController.java @@ -124,24 +124,7 @@ public ResponseEntity deleteInvitation(@PathVariable("id") Long id, @PutMapping("/{id}") public ResponseEntity> resendInvitation(@PathVariable("id") Long id, @Parameter(hidden = true) User user) { - LOG.debug(String.format("/resendInvitation/%s by user %s", id, user.getEduPersonPrincipalName())); - //We need to assert validations on the roles soo we need to load them - Invitation invitation = invitationRepository.findById(id).orElseThrow(() -> new NotFoundException("Invitation not found")); - List requestedRoles = invitation.getRoles().stream() - .map(InvitationRole::getRole).toList(); - Authority intendedAuthority = invitation.getIntendedAuthority(); - UserPermissions.assertValidInvitation(user, intendedAuthority, requestedRoles); - List groupedProviders = manage.getGroupedProviders(requestedRoles); - - mailBox.sendInviteMail(user, invitation, groupedProviders, invitation.getLanguage()); - if (invitation.getExpiryDate().isBefore(Instant.now())) { - invitation.setExpiryDate(Instant.now().plus(Period.ofDays(14))); - invitationRepository.save(invitation); - } - - AccessLogger.invitation(LOG, Event.Resend, invitation); - - return Results.createResult(); + return this.invitationOperations.resendInvitation(id, user, null); } @GetMapping("public") diff --git a/server/src/main/java/access/api/InvitationOperations.java b/server/src/main/java/access/api/InvitationOperations.java index 365d716d..6c962b3a 100644 --- a/server/src/main/java/access/api/InvitationOperations.java +++ b/server/src/main/java/access/api/InvitationOperations.java @@ -7,6 +7,7 @@ import access.logging.Event; import access.mail.MailBox; import access.model.*; +import access.repository.InvitationRepository; import access.security.RemoteUser; import access.security.RemoteUserPermissions; import access.security.UserPermissions; @@ -18,9 +19,11 @@ import org.springframework.util.CollectionUtils; import java.time.Instant; +import java.time.Period; import java.time.temporal.ChronoUnit; import java.util.Comparator; import java.util.List; +import java.util.Map; import static java.util.stream.Collectors.toSet; @@ -106,4 +109,38 @@ public ResponseEntity sendInvitation(InvitationRequest invit return ResponseEntity.status(HttpStatus.CREATED).body(new InvitationResponse(HttpStatus.CREATED.value(), recipientInvitationURLs)); } + public ResponseEntity> resendInvitation(Long id, + User user, + RemoteUser remoteUser) { + String name = user != null ? user.getEduPersonPrincipalName() : remoteUser.getDisplayName(); + + LOG.debug(String.format("/resendInvitation/%s by user %s", id, name)); + + //We need to assert validations on the roles soo we need to load them + InvitationRepository invitationRepository = this.invitationResource.getInvitationRepository(); + + Invitation invitation = invitationRepository.findById(id).orElseThrow(() -> new NotFoundException("Invitation not found")); + List requestedRoles = invitation.getRoles().stream() + .map(InvitationRole::getRole).toList(); + Authority intendedAuthority = invitation.getIntendedAuthority(); + if (user != null) { + UserPermissions.assertValidInvitation(user, intendedAuthority, requestedRoles); + } else { + RemoteUserPermissions.assertApplicationAccess(remoteUser, requestedRoles); + } + + List groupedProviders = this.invitationResource.getManage().getGroupedProviders(requestedRoles); + Provisionable provisionable = user != null ? user : remoteUser; + + this.invitationResource.getMailBox().sendInviteMail(provisionable, invitation, groupedProviders, invitation.getLanguage()); + if (invitation.getExpiryDate().isBefore(Instant.now())) { + invitation.setExpiryDate(Instant.now().plus(Period.ofDays(14))); + invitationRepository.save(invitation); + } + + AccessLogger.invitation(LOG, Event.Resend, invitation); + + return Results.createResult(); + } + } diff --git a/server/src/main/java/access/api/RoleController.java b/server/src/main/java/access/api/RoleController.java index 5c53a053..88cd0699 100644 --- a/server/src/main/java/access/api/RoleController.java +++ b/server/src/main/java/access/api/RoleController.java @@ -78,7 +78,7 @@ public RoleController(Config config, public ResponseEntity> rolesByApplication(@Parameter(hidden = true) User user) { LOG.debug(String.format("/roles for user %s", user.getEduPersonPrincipalName())); - if (user.isSuperUser() && !config.isRoleSearchRequired()) { + if (user.isSuperUser()) { return ResponseEntity.ok(manage.addManageMetaData(roleRepository.findAll())); } UserPermissions.assertAuthority(user, Authority.INSTITUTION_ADMIN); diff --git a/server/src/main/java/access/internal/InternalInviteController.java b/server/src/main/java/access/internal/InternalInviteController.java index 67a7b71d..f4f4a4f1 100644 --- a/server/src/main/java/access/internal/InternalInviteController.java +++ b/server/src/main/java/access/internal/InternalInviteController.java @@ -6,20 +6,25 @@ import access.logging.Event; import access.mail.MailBox; import access.manage.Manage; -import access.model.InvitationRequest; -import access.model.InvitationResponse; -import access.model.Role; -import access.model.UserRole; +import access.model.*; import access.provision.ProvisioningService; import access.provision.scim.GroupURN; import access.repository.*; import access.security.RemoteUser; import access.security.RemoteUserPermissions; +import io.swagger.v3.oas.annotations.Hidden; +import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import lombok.Getter; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; @@ -28,10 +33,7 @@ import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; -import java.util.UUID; +import java.util.*; import static access.SwaggerOpenIdConfig.BASIC_AUTHENTICATION_SCHEME_NAME; @@ -61,6 +63,7 @@ public class InternalInviteController implements ApplicationResource, Invitation private final RoleOperations roleOperations; private final InvitationOperations invitationOperations; private final UserRoleOperations userRoleOperations; + private final String groupUrnPrefix; public InternalInviteController(RoleRepository roleRepository, @@ -70,7 +73,8 @@ public InternalInviteController(RoleRepository roleRepository, MailBox mailBox, Manage manage, InvitationRepository invitationRepository, - ProvisioningService provisioningService) { + ProvisioningService provisioningService, + @Value("${voot.group_urn_domain}") String groupUrnPrefix) { this.roleRepository = roleRepository; this.userRoleRepository = userRoleRepository; this.applicationRepository = applicationRepository; @@ -79,13 +83,15 @@ public InternalInviteController(RoleRepository roleRepository, this.manage = manage; this.invitationRepository = invitationRepository; this.provisioningService = provisioningService; + this.groupUrnPrefix = groupUrnPrefix; this.userRoleOperations = new UserRoleOperations(this); this.roleOperations = new RoleOperations(this); this.invitationOperations = new InvitationOperations(this); } @GetMapping("/roles") - @PreAuthorize("hasRole('SP_DASHBOARD')") + @PreAuthorize("hasAnyRole('SP_DASHBOARD')") + @Hidden public ResponseEntity> rolesByApplication(@Parameter(hidden = true) @AuthenticationPrincipal RemoteUser remoteUser) { LOG.debug(String.format("/roles for user %s", remoteUser.getName())); @@ -100,6 +106,7 @@ public ResponseEntity> rolesByApplication(@Parameter(hidden = true) @ @GetMapping("/roles/{id}") @PreAuthorize("hasRole('SP_DASHBOARD')") + @Hidden public ResponseEntity role(@PathVariable("id") Long id, @Parameter(hidden = true) @AuthenticationPrincipal RemoteUser remoteUser) { LOG.debug(String.format("/role/%s for user %s", id, remoteUser.getName())); @@ -114,6 +121,92 @@ public ResponseEntity role(@PathVariable("id") Long id, @PostMapping("/roles") @PreAuthorize("hasRole('SP_DASHBOARD')") + @Operation(summary = "Create a Role", + description = "Create a Role linked to a SP in Manage. Note that the required application object needs to be pre-configured during deployment.", + requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody( + useParameterTypeSchema = true, + content = {@Content(examples = {@ExampleObject(value = """ + { + "name": "Required role name", + "shortName": "Required short name - may be copy of name", + "description": "Required role description", + "defaultExpiryDays": 365, + "applicationUsages": [ + { + "landingPage": "http://landingpage.com", + "application": { + "manageId": "4", + "manageType": "SAML20_SP" + } + } + ] + } + """ + )})} + ), + responses = { + @ApiResponse(responseCode = "201", description = "Created", + content = {@Content(schema = @Schema(implementation = Role.class), + examples = {@ExampleObject(value = """ + { + "id": 42114, + "name": "Required role name", + "shortName": "required_role_name", + "description": "Required role description", + "urn": "urn:mace:surf.nl:test.surfaccess.nl:74fd8059-7558-4454-8393-fd84f74c4907:required_role_name", + "defaultExpiryDays": 365, + "enforceEmailEquality": false, + "eduIDOnly": false, + "blockExpiryDate": false, + "overrideSettingsAllowed": false, + "teamsOrigin": false, + "identifier": "74fd8059-7558-4454-8393-fd84f74c4907", + "remoteApiUser": "SP Dashboard", + "applicationUsages": [ + { + "id": 49203, + "landingPage": "http://landingpage.com", + "application": { + "id": 41904, + "manageId": "4", + "manageType": "SAML20_SP" + } + } + ], + "auditable": { + "createdAt": 1729254283, + "createdBy": "sp_dashboard" + }, + "applicationMaps": [ + { + "OrganizationName:en": "SURF bv", + "landingPage": "http://landingpage.com", + "logo": "https://static.surfconext.nl/media/idp/surfconext.png", + "entityid": "https://research", + "name:en": "Research EN", + "id": "4", + "_id": "4", + "type": "saml20_sp", + "url": "https://default-url-research.org", + "name:nl": "Research NL" + } + ] + } + """ + )})}), + @ApiResponse(responseCode = "400", description = "BadRequest", + content = {@Content(schema = @Schema(implementation = StatusResponse.class), + examples = {@ExampleObject(value = """ + { + "timestamp": 1717672263253, + "status": 400, + "error": "BadRequest", + "exception": "access.exception.UserRestrictionException", + "message": "No access to application", + "path": "/api/internal/invite/invitations" + } + """ + )})})}) public ResponseEntity newRole(@Validated @RequestBody Role role, @Parameter(hidden = true) @AuthenticationPrincipal RemoteUser remoteUser) { role.setRemoteApiUser(remoteUser.getName()); @@ -123,20 +216,25 @@ public ResponseEntity newRole(@Validated @RequestBody Role role, LOG.debug(String.format("New role '%s' by user %s", role.getName(), remoteUser.getName())); - return saveOrUpdate(role, remoteUser); + role.setUrn(GroupURN.urnFromRole(groupUrnPrefix, role)); + + Role savedRole = saveOrUpdate(role, remoteUser); + return ResponseEntity.status(HttpStatus.CREATED).body(savedRole); } @PutMapping("/roles") @PreAuthorize("hasRole('SP_DASHBOARD')") + @Hidden public ResponseEntity updateRole(@Validated @RequestBody Role role, @Parameter(hidden = true) @AuthenticationPrincipal RemoteUser remoteUser) { LOG.debug(String.format("Update role '%s' by user %s", role.getName(), remoteUser.getName())); - return saveOrUpdate(role, remoteUser); + return ResponseEntity.status(HttpStatus.CREATED).body(saveOrUpdate(role, remoteUser)); } @DeleteMapping("/roles/{id}") @PreAuthorize("hasRole('SP_DASHBOARD')") + @Hidden public ResponseEntity deleteRole(@PathVariable("id") Long id, @Parameter(hidden = true) @AuthenticationPrincipal RemoteUser remoteUser) { Role role = roleRepository.findById(id).orElseThrow(() -> new NotFoundException("Role not found")); @@ -156,21 +254,96 @@ public ResponseEntity deleteRole(@PathVariable("id") Long id, @PostMapping("/invitations") @PreAuthorize("hasRole('SP_DASHBOARD')") + @Operation(summary = "Invite member for existing Role", + description = "Invite a member for an existing role. An invitation email will be send", + requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody( + useParameterTypeSchema = true, + content = {@Content(examples = {@ExampleObject(value = """ + { + "intendedAuthority": "INVITER", + "message": "Personal message included in the email", + "language": "en", + "enforceEmailEquality": false, + "eduIDOnly": false, + "guestRoleIncluded": true, + "suppressSendingEmails": false, + "invites": [ + "admin@service.org" + ], + "roleIdentifiers": [ + 99 + ], + "roleExpiryDate": 1760788376, + "expiryDate": 1730461976 + } + """ + )})} + ), + responses = { + @ApiResponse(responseCode = "201", description = "Created", + content = {@Content(schema = @Schema(implementation = InvitationResponse.class), + examples = {@ExampleObject(value = """ + { + "status": 201, + "recipientInvitationURLs": [ + { + "recipient": "admin@service.nl", + "invitationURL": "https://invite.test.surfconext.nl/invitation/accept?{hash}" + } + ] + } + """ + )})}), + @ApiResponse(responseCode = "400", description = "BadRequest", + content = {@Content(schema = @Schema(implementation = StatusResponse.class), + examples = {@ExampleObject(value = """ + { + "timestamp": 1717672263253, + "status": 400, + "error": "BadRequest", + "exception": "access.exception.UserRestrictionException", + "message": "No access to application", + "path": "/api/internal/invite/invitations" + } + """ + )})}), + @ApiResponse(responseCode = "404", description = "Role not found", + content = {@Content(schema = @Schema(implementation = StatusResponse.class), + examples = {@ExampleObject(value = """ + { + "timestamp": 1717672263253, + "status": 404, + "error": "Not found", + "exception": "access.exception.NotFoundException", + "message": "Role not found", + "path": "/api/internal/invite/invitations" + } + """ + )})})}) public ResponseEntity newInvitation(@Validated @RequestBody InvitationRequest invitationRequest, @Parameter(hidden = true) @AuthenticationPrincipal RemoteUser remoteUser) { return this.invitationOperations.sendInvitation(invitationRequest, null, remoteUser); } + @PutMapping("/invitations/{id}") + @PreAuthorize("hasRole('SP_DASHBOARD')") + @Hidden + public ResponseEntity> resendInvitation(@PathVariable("id") Long id, + @Parameter(hidden = true) @AuthenticationPrincipal RemoteUser remoteUser) { + return this.invitationOperations.resendInvitation(id, null, remoteUser); + } + @GetMapping("user_roles/{roleId}") @PreAuthorize("hasRole('SP_DASHBOARD')") @Transactional + @Hidden public ResponseEntity> byRole(@PathVariable("roleId") Long roleId, @Parameter(hidden = true) @AuthenticationPrincipal RemoteUser remoteUser) { return this.userRoleOperations.userRolesByRole(roleId, role -> RemoteUserPermissions.assertApplicationAccess(remoteUser, role)); } - private ResponseEntity saveOrUpdate(Role role, RemoteUser remoteUser) { + private Role saveOrUpdate(Role role, RemoteUser remoteUser) { roleOperations.assertValidRole(role); RemoteUserPermissions.assertApplicationAccess(remoteUser, role); @@ -198,7 +371,7 @@ private ResponseEntity saveOrUpdate(Role role, RemoteUser remoteUser) { AccessLogger.role(LOG, isNew ? Event.Created : Event.Updated, remoteUser, role); - return ResponseEntity.ok(saved); + return saved; } } diff --git a/server/src/main/java/access/model/StatusResponse.java b/server/src/main/java/access/model/StatusResponse.java new file mode 100644 index 00000000..0884d66a --- /dev/null +++ b/server/src/main/java/access/model/StatusResponse.java @@ -0,0 +1,15 @@ +package access.model; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class StatusResponse implements Serializable { + + private int status; +} diff --git a/server/src/test/java/access/internal/InternalInviteControllerTest.java b/server/src/test/java/access/internal/InternalInviteControllerTest.java index 89750796..595f8cc5 100644 --- a/server/src/test/java/access/internal/InternalInviteControllerTest.java +++ b/server/src/test/java/access/internal/InternalInviteControllerTest.java @@ -3,6 +3,7 @@ import access.AbstractTest; import access.manage.EntityType; import access.model.*; +import com.fasterxml.jackson.core.JsonProcessingException; import io.restassured.common.mapper.TypeRef; import io.restassured.http.ContentType; import org.junit.jupiter.api.Test; @@ -24,7 +25,8 @@ class InternalInviteControllerTest extends AbstractTest { @Test void createWithAPIUser() throws Exception { - Role role = new Role("New", "New desc", application("4", EntityType.SAML20_SP), 365, false, false); + Role role = new Role("Required role name", "Required role description", application("4", EntityType.SAML20_SP), + 365, false, false); super.stubForManagerProvidersByIdIn(EntityType.SAML20_SP, List.of("4")); super.stubForManageProvisioning(List.of("1")); @@ -40,6 +42,7 @@ void createWithAPIUser() throws Exception { .as(new TypeRef<>() { }); assertNotNull(newRole.getId()); + System.out.println(objectMapper.writeValueAsString(newRole)); } @Test @@ -136,6 +139,43 @@ void newInvitation() { assertEquals(1, ((List) results.get("recipientInvitationURLs")).size()); } + @Test + void resendInvitation() { + // This invitation is for the application Research + Invitation invitation = invitationRepository.findByHash(Authority.MANAGER.name()).get(); + invitation.setExpiryDate(Instant.now().minus(5, ChronoUnit.DAYS)); + invitationRepository.save(invitation); + + given() + .when() + .auth().preemptive().basic("sp_dashboard", "secret") + .accept(ContentType.JSON) + .contentType(ContentType.JSON) + .pathParam("id", invitation.getId()) + .put("/api/internal/invite/invitations/{id}") + .then() + .statusCode(201); + + Invitation savedInvitation = invitationRepository.findByHash(Authority.MANAGER.name()).get(); + assertTrue(savedInvitation.getExpiryDate().isAfter(Instant.now().plus(13, ChronoUnit.DAYS))); + } + + @Test + void resendInvitationNotAllowed() { + // This invitation is for the application Research + Invitation invitation = invitationRepository.findByHash(Authority.GUEST.name()).get(); + + given() + .when() + .auth().preemptive().basic("sp_dashboard", "secret") + .accept(ContentType.JSON) + .contentType(ContentType.JSON) + .pathParam("id", invitation.getId()) + .put("/api/internal/invite/invitations/{id}") + .then() + .statusCode(403); + } + @Test void userRolesByRole() { Long roleId = roleRepository.findByName("Research").get().getId(); @@ -148,8 +188,17 @@ void userRolesByRole() { .get("/api/internal/invite/user_roles/{roleId}") .as(new TypeRef<>() { }); - assertEquals(1, userRoles.size()); } + @Test + void delme() throws JsonProcessingException { + InvitationResponse invitationResponse = new InvitationResponse( + 201, + List.of(new RecipientInvitationURL("admin@service.nl", "https://invite.test.surfconext.nl/invitation/accept?{hash}")) + ); + String json = objectMapper.writeValueAsString(invitationResponse); + System.out.println(json); + } + } \ No newline at end of file