Skip to content

Commit

Permalink
Merge branch 'feature/external-role-api'
Browse files Browse the repository at this point in the history
  • Loading branch information
oharsta committed Aug 20, 2024
2 parents e905064 + b693f11 commit f0d678a
Show file tree
Hide file tree
Showing 15 changed files with 114 additions and 17 deletions.
8 changes: 7 additions & 1 deletion server/src/main/java/access/api/RoleController.java
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import access.repository.ApplicationRepository;
import access.repository.ApplicationUsageRepository;
import access.repository.RoleRepository;
import access.security.RemoteUser;
import access.security.UserPermissions;
import access.validation.URLFormatValidator;
import io.swagger.v3.oas.annotations.Parameter;
Expand All @@ -22,6 +23,8 @@
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
Expand Down Expand Up @@ -136,7 +139,10 @@ public ResponseEntity<List<Role>> search(@RequestParam(value = "query") String q
}

@PostMapping("")
public ResponseEntity<Role> newRole(@Validated @RequestBody Role role, @Parameter(hidden = true) User user) {
public ResponseEntity<Role> newRole(@Validated @RequestBody Role role,
@Parameter(hidden = true) User user,
@Parameter(hidden = true) @AuthenticationPrincipal RemoteUser remoteUser) {
//TODO differentiate between RemoteUser and User
UserPermissions.assertAuthority(user, Authority.INSTITUTION_ADMIN);
role.setShortName(GroupURN.sanitizeRoleShortName(role.getShortName()));
role.setIdentifier(UUID.randomUUID().toString());
Expand Down
4 changes: 3 additions & 1 deletion server/src/main/java/access/api/UserController.java
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,9 @@ public ResponseEntity<Map<String, Integer>> logout(HttpServletRequest request) {
@GetMapping("ms-accept-return/{manageId}/{userId}")
public View msAcceptReturn(@PathVariable("manageId") String manageId, @PathVariable("userId") Long userId) {
User user = userRepository.findById(userId).orElseThrow(() -> new NotFoundException("User not found"));
LOG.debug(String.format("Return from MS accept. User %s",user.getEduPersonPrincipalName()));

LOG.info(String.format("Return from MS accept. User %s",user.getEduPersonPrincipalName()));

Map<String, Object> provisioningMap = manage.providerById(EntityType.PROVISIONING, manageId);
Provisioning provisioning = new Provisioning(provisioningMap);
AtomicReference<String> redirectReference = new AtomicReference<>(this.config.getWelcomeUrl());
Expand Down
4 changes: 2 additions & 2 deletions server/src/main/java/access/cron/RoleExpirationNotifier.java
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,8 @@ public List<String> doSweep() {
List<GroupedProviders> groupedProviders = manage.getGroupedProviders(List.of(userRole.getRole()));
GroupedProviders groupedProvider = groupedProviders.isEmpty() ? null : groupedProviders.get(0);
String mail = mailBox.sendUserRoleExpirationNotificationMail(userRole, groupedProvider, roleExpirationNotificationDays);
userRole.setExpiryNotifications(1);
userRoleRepository.save(userRole);
//https://stackoverflow.com/a/75121707
userRoleRepository.updateExpiryNotifications(1, userRole.getId());

LOG.info("Send expiration notification mail to " + userRole.getUser().getEmail());

Expand Down
5 changes: 3 additions & 2 deletions server/src/main/java/access/manage/Manage.java
Original file line number Diff line number Diff line change
Expand Up @@ -125,8 +125,9 @@ default List<GroupedProviders> getGroupedProviders(List<Role> requestedRoles) {
.map(application -> new ManageIdentifier(application.getManageId(), application.getManageType()))
.collect(Collectors.toSet())
.stream()
.map(manageIdentifier -> {
Map<String, Object> provider = providerById(manageIdentifier.manageType(), manageIdentifier.manageId());
.map(manageIdentifier -> providerById(manageIdentifier.manageType(), manageIdentifier.manageId()))
.filter(provider -> !CollectionUtils.isEmpty(provider))
.map(provider -> {
String id = (String) provider.get("id");
return new GroupedProviders(
provider,
Expand Down
3 changes: 3 additions & 0 deletions server/src/main/java/access/manage/RemoteManage.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import org.springframework.core.io.ClassPathResource;
import org.springframework.http.client.support.BasicAuthenticationInterceptor;
import org.springframework.util.CollectionUtils;
import org.springframework.web.client.ResponseErrorHandler;
import org.springframework.web.client.RestTemplate;

import java.io.IOException;
Expand All @@ -28,6 +29,8 @@ public RemoteManage(String url, String user, String password, ObjectMapper objec
this.queries = objectMapper.readValue(new ClassPathResource("/manage/query_templates.json").getInputStream(), new TypeReference<>() {
});
restTemplate.getInterceptors().add(new BasicAuthenticationInterceptor(user, password));
ResponseErrorHandler resilientErrorHandler = new ResilientErrorHandler();
restTemplate.setErrorHandler(resilientErrorHandler);
}

@Override
Expand Down
37 changes: 37 additions & 0 deletions server/src/main/java/access/manage/ResilientErrorHandler.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package access.manage;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.web.client.ResponseErrorHandler;

import java.io.IOException;
import java.net.URI;

public class ResilientErrorHandler implements ResponseErrorHandler {

private static final Log LOG = LogFactory.getLog(ResilientErrorHandler.class);

@Override
public boolean hasError(ClientHttpResponse response) throws IOException {
HttpStatusCode statusCode = response.getStatusCode();
return statusCode.isError();
}

@Override
public void handleError(ClientHttpResponse response) throws IOException {
this.handleError(null, null, response);
}

@Override
public void handleError(URI url, HttpMethod method, ClientHttpResponse response) throws IOException {
//ignore
LOG.warn(String.format("Error from Manage: %s, %s, %s, %s",
url,
method,
response.getStatusText(),
new String(response.getBody().readAllBytes())));
}
}
11 changes: 6 additions & 5 deletions server/src/main/java/access/repository/UserRoleRepository.java
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
package access.repository;

import access.model.Authority;
import access.model.Role;
import access.model.UserRole;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
Expand All @@ -12,9 +10,7 @@
import org.springframework.transaction.annotation.Transactional;

import java.time.Instant;
import java.util.Collection;
import java.util.List;
import java.util.Optional;

@Repository
public interface UserRoleRepository extends JpaRepository<UserRole, Long> {
Expand All @@ -28,7 +24,12 @@ public interface UserRoleRepository extends JpaRepository<UserRole, Long> {
List<UserRole> findByRoleName(String roleName);

@Modifying
@Query("DELETE FROM user_roles WHERE id = ?1")
@Query(value = "DELETE FROM user_roles WHERE id = ?1", nativeQuery = true)
@Transactional(isolation = Isolation.SERIALIZABLE)
void deleteUserRoleById(Long id);

@Modifying
@Query(value = "UPDATE user_roles SET expiry_notifications= ?1 WHERE id = ?2", nativeQuery = true)
@Transactional(isolation = Isolation.SERIALIZABLE)
void updateExpiryNotifications(Integer expiryNotifications, Long id);
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ private void fixPassword(RemoteUser remoteUser) {
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
RemoteUser remoteUser = users.get(username);
return remoteUser != null ? new RemoteUser(remoteUser) : null;
//Need to make copy, otherwise the password is erased. See RemoteUser#CredentialsContainer
if (remoteUser == null) {
throw new UsernameNotFoundException("User not found: " + username);
}
return new RemoteUser(remoteUser);
}
}
1 change: 1 addition & 0 deletions server/src/main/java/access/security/RemoteUser.java
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public RemoteUser(RemoteUser remoteUser) {

@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
//Convention dictates upperCase Role names to be used in @PreAuthorize annotations
return scopes.stream()
.map(scope -> new SimpleGrantedAuthority("ROLE_" + scope.toUpperCase()))
.collect(Collectors.toList());
Expand Down
10 changes: 8 additions & 2 deletions server/src/main/java/access/security/SecurityConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ public class SecurityConfig {
private final ProvisioningService provisioningService;
private final ExternalApiConfiguration externalApiConfiguration;

private final RequestHeaderRequestMatcher apiTokenRequestMatcher = new RequestHeaderRequestMatcher(API_TOKEN_HEADER);

@Autowired
public SecurityConfig(ClientRegistrationRepository clientRegistrationRepository,
InvitationRepository invitationRepository,
Expand Down Expand Up @@ -188,11 +190,15 @@ SecurityFilterChain basicAuthenticationSecurityFilterChain(HttpSecurity http) th
"/api/aa/**",
"/api/external/v1/aa/**",
"/api/deprovision/**",
"/api/external/v1/deprovision/**")
"/api/external/v1/deprovision/**",
"/api/external/v1/roles")
.sessionManagement(c -> c
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.authorizeHttpRequests(c -> c
//The API token is secured in the UserHandlerMethodArgumentResolver
.requestMatchers(apiTokenRequestMatcher)
.permitAll()
.anyRequest()
.authenticated()
)
Expand All @@ -203,7 +209,7 @@ SecurityFilterChain basicAuthenticationSecurityFilterChain(HttpSecurity http) th
@Bean
@Order(3)
SecurityFilterChain jwtSecurityFilterChain(HttpSecurity http) throws Exception {
final RequestHeaderRequestMatcher apiTokenRequestMatcher = new RequestHeaderRequestMatcher(API_TOKEN_HEADER);

http.csrf(c -> c.disable())
.securityMatcher("/api/external/v1/**")
.authorizeHttpRequests(c -> c
Expand Down
4 changes: 2 additions & 2 deletions server/src/main/java/access/teams/TeamsController.java
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,8 @@ public ResponseEntity<Map<String, Integer>> migrateTeam(@RequestBody Team team)

private boolean applicationExists(Application application) {
try {
manage.providerById(application.getManageType(), application.getManageId());
return true;
Map<String, Object> provider = manage.providerById(application.getManageType(), application.getManageId());
return provider != null;
} catch (RuntimeException e) {
return false;
}
Expand Down
2 changes: 1 addition & 1 deletion server/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ email:
# ignored then and the different manage entities are loaded from json files in `server/src/main/resources/manage`.
# Each *.json file in this directory corresponds with the contents of that specific entity_type.
# To test the provisioning (e.g. SCIM, EVA, Graphp) with real endpoints you can set the manage.local property below to
# False and then the provisioning.local.json file is used which is not in git as it is n .gitignore. You can safely
# True and then the provisioning.local.json file is used which is not in git as it is n .gitignore. You can safely
# configure real users / passwords and test against those. See server/src/main/java/access/manage/ManageConf.java
# and server/src/main/java/access/manage/LocalManage.java to see how it works.
manage:
Expand Down
22 changes: 22 additions & 0 deletions server/src/test/java/access/api/RoleControllerTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;

import java.lang.reflect.Type;
import java.util.List;
import java.util.Map;
import java.util.Set;
Expand Down Expand Up @@ -434,6 +435,27 @@ void deleteRoleWithAPI() throws Exception {
assertEquals(0, roleRepository.search("wiki", 1).size());
}

@Test
void createWithAPIUser() throws Exception {
Role role = new Role("New", "New desc", application("1", EntityType.SAML20_SP), 365, false, false);

super.stubForManagerProvidersByIdIn(EntityType.SAML20_SP, List.of("1"));
super.stubForManageProvisioning(List.of("1"));
super.stubForCreateScimRole();

given()
.when()
.auth().preemptive().basic("voot", "secret")
.accept(ContentType.JSON)
.contentType(ContentType.JSON)
.body(role)
.post("/api/external/v1/roles")
.then()
.statusCode(400);
//TODO
// assertNotNull(result.get("id"));
}

@Test
void rolesByApplicationSuperUserWithAPIToken() {
super.stubForManagerProvidersByIdIn(EntityType.SAML20_SP, List.of("1", "2", "3", "4"));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ void sweep() {

userRole = userRoleRepository.findByRoleName("Mail").get(0);
assertEquals(1, userRole.getExpiryNotifications());

Instant instant = Instant.now().plus(100, ChronoUnit.DAYS);
List<UserRole> userRoles = userRoleRepository.findByEndDateBeforeAndExpiryNotifications(instant, 0);
assertEquals(0, userRoles.size());
}

@Test
Expand Down
10 changes: 10 additions & 0 deletions server/src/test/java/access/manage/RemoteManageTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

import static com.github.tomakehurst.wiremock.client.WireMock.*;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;

class RemoteManageTest extends AbstractTest {

Expand Down Expand Up @@ -64,4 +65,13 @@ void providersByIdIn() throws JsonProcessingException {
assertEquals(providers, remoteProviders);
}

@Test
void providerByIdExceptionHandling() {
stubFor(get(urlPathMatching("/manage/api/internal/metadata/saml20_sp/1")).willReturn(aResponse()
.withHeader("Content-Type", "application/json")
.withStatus(404)));
Map<String, Object> remoteProvider = manage.providerById(EntityType.SAML20_SP, "1");
assertNull(remoteProvider);
}

}

0 comments on commit f0d678a

Please sign in to comment.