Skip to content

Commit c66cb41

Browse files
authored
Merge pull request #643 from OpenConext/bug/#640-scim-user-deprovisioning
Bug/#640 SCIM user deprovisioning
2 parents 8bcb313 + 9566d04 commit c66cb41

15 files changed

Lines changed: 223 additions & 36 deletions

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,3 +192,17 @@ To build and deploy (the latter requires credentials in your maven settings):
192192
```bash
193193
mvn clean deploy
194194
```
195+
196+
## [Upgrade](#upgrade)
197+
198+
To check the pom.xml with the latest versions, run
199+
```
200+
cd server
201+
mvn versions:display-dependency-updates -DprocessDependencyManagement=false -DdependencyIncludes=*:*
202+
```
203+
To see the latest versions report for the client run
204+
```
205+
cd client
206+
nvm use
207+
yarn outdated
208+
```

client/src/pages/App.jsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {Footer} from "../components/Footer";
1212
import {BreadCrumb} from "../components/BreadCrumb";
1313
import {Invitation} from "./Invitation";
1414
import {login} from "../utils/Login";
15-
import NotFound from "./NotFound";
15+
import {NotFound} from "./NotFound";
1616
import {Impersonating} from "../components/Impersonating";
1717
import RefreshRoute from "./RefreshRoute";
1818
import {InviteOnly} from "./InviteOnly";
@@ -27,6 +27,7 @@ import {Application} from "./Application";
2727
import {System} from "./System";
2828
import {flushSync} from "react-dom";
2929
import {UserTokens} from "./UserTokens";
30+
import {Busy} from "./Busy";
3031

3132

3233
export const App = () => {
@@ -133,6 +134,7 @@ export const App = () => {
133134
<Route path="login" element={<Login/>}/>
134135
<Route path="deadend" element={<InviteOnly/>}/>
135136
<Route path="missingAttributes" element={<MissingAttributes/>}/>
137+
<Route path="/home/login" element={<Busy/>}/>
136138
<Route path="/*" element={<NotFound/>}/>
137139
</Routes>}
138140
</div>

client/src/pages/Busy.jsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import "./NotFound.scss";
2+
import React from "react";
3+
import {Loader} from "@surfnet/sds";
4+
5+
export const Busy = () => {
6+
7+
return (
8+
<Loader/>
9+
);
10+
}

client/src/pages/NotFound.jsx

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ import React from "react";
33
import NotFoundLogo from "../icons/undraw_page_not_found_re_e9o6.svg?url";
44
import I18n from "../locale/I18n";
55

6-
const NotFound = () => (
7-
<div className={"not-found"}>
8-
<img src={NotFoundLogo} alt={I18n.t("notFound.alt")}/>
9-
</div>
10-
);
11-
export default NotFound;
6+
export const NotFound = () => {
7+
8+
return (
9+
<div className={"not-found"}>
10+
<img src={NotFoundLogo} alt={I18n.t("notFound.alt")}/>
11+
</div>
12+
);
13+
}

server/src/main/java/invite/api/RoleController.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import invite.repository.ApplicationRepository;
1414
import invite.repository.ApplicationUsageRepository;
1515
import invite.repository.RoleRepository;
16+
import invite.repository.UserRoleRepository;
1617
import invite.security.UserPermissions;
1718
import io.swagger.v3.oas.annotations.Parameter;
1819
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
@@ -212,7 +213,9 @@ public ResponseEntity<Void> deleteRole(@PathVariable("id") Long id,
212213
}
213214

214215
provisioningService.deleteGroupRequest(role);
215-
roleRepository.delete(role);
216+
provisioningService.deleteUserRequest(role);
217+
roleRepository.deleteRoleById(role.getId());
218+
216219
AccessLogger.role(LOG, Event.Deleted, user, role);
217220
return Results.deleteResult();
218221
}

server/src/main/java/invite/api/UserRoleController.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import invite.exception.NotFoundException;
77
import invite.logging.AccessLogger;
88
import invite.logging.Event;
9+
import invite.manage.ManageIdentifier;
910
import invite.model.*;
1011
import invite.provision.ProvisioningService;
1112
import invite.provision.scim.OperationType;
@@ -39,6 +40,7 @@
3940
import java.util.Map;
4041
import java.util.Objects;
4142
import java.util.Optional;
43+
import java.util.Set;
4244

4345
import static invite.SwaggerOpenIdConfig.API_TOKENS_SCHEME_NAME;
4446
import static invite.SwaggerOpenIdConfig.OPEN_ID_SCHEME_NAME;
@@ -226,7 +228,8 @@ public ResponseEntity<Void> deleteUserRole(@PathVariable("id") Long id,
226228
LOG.debug(String.format("DELETE user_roles/%s for user %s",id , user.getEduPersonPrincipalName()));
227229
UserRole userRole = userRoleRepository.findById(id).orElseThrow(() -> new NotFoundException("UserRole not found"));
228230
// Users are allowed to remove themselves from a role
229-
if (!userRole.getUser().getId().equals(user.getId())) {
231+
User userOfUserRole = userRole.getUser();
232+
if (!userOfUserRole.getId().equals(user.getId())) {
230233
UserPermissions.assertValidInvitation(user, isGuest ? Authority.GUEST : userRole.getAuthority(), List.of(userRole.getRole()));
231234
}
232235
if (userRole.isGuestRoleIncluded()) {
@@ -242,6 +245,9 @@ public ResponseEntity<Void> deleteUserRole(@PathVariable("id") Long id,
242245
provisioningService.updateGroupRequest(userRole, OperationType.Remove);
243246
provisioningService.deleteUserRoleRequest(userRole);
244247
userRoleAuditService.logAction(userRole, UserRoleAudit.ActionType.DELETE);
248+
// Deprovision the user for all provisionings which are exclusively used in this userRole
249+
provisioningService.deleteUserRequest(userOfUserRole, userRole);
250+
245251
userRoleRepository.deleteUserRoleById(id);
246252
AccessLogger.userRole(LOG, Event.Deleted, user, userRole);
247253
}

server/src/main/java/invite/model/User.java

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,16 @@ public void removeUserRole(UserRole role) {
200200

201201
@JsonIgnore
202202
public Set<ManageIdentifier> manageIdentifierSet() {
203-
return userRoles.stream()
203+
return manageIdentifierSet(this.userRoles);
204+
}
205+
206+
@JsonIgnore
207+
public Set<ManageIdentifier> manageIdentifierSet(UserRole userRole) {
208+
return manageIdentifierSet(Set.of(userRole));
209+
}
210+
211+
private Set<ManageIdentifier> manageIdentifierSet(Set<UserRole> userRoleSet) {
212+
return userRoleSet.stream()
204213
.filter(userRole -> userRole.getAuthority().equals(Authority.GUEST) || userRole.isGuestRoleIncluded())
205214
.map(userRole -> userRole.getRole().getApplicationUsages())
206215
.flatMap(Collection::stream)

server/src/main/java/invite/provision/ProvisioningService.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ public interface ProvisioningService {
2121

2222
void deleteUserRequest(User user);
2323

24+
void deleteUserRequest(User user, UserRole userRole);
25+
26+
void deleteUserRequest(Role role);
27+
2428
void newGroupRequest(Role role);
2529

2630
void updateGroupRequest(UserRole userRole, OperationType operationType);

server/src/main/java/invite/provision/ProvisioningServiceDefault.java

Lines changed: 110 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,79 @@
11
package invite.provision;
22

3+
import com.fasterxml.jackson.databind.ObjectMapper;
4+
import crypto.KeyStore;
35
import invite.eduid.EduID;
46
import invite.eduid.EduIDProvision;
57
import invite.exception.InvalidInputException;
68
import invite.exception.RemoteException;
79
import invite.manage.Manage;
810
import invite.manage.ManageIdentifier;
9-
import invite.model.*;
11+
import invite.model.Application;
12+
import invite.model.Authority;
13+
import invite.model.Provisionable;
14+
import invite.model.RemoteProvisionedGroup;
15+
import invite.model.RemoteProvisionedUser;
16+
import invite.model.Role;
17+
import invite.model.User;
18+
import invite.model.UserRole;
1019
import invite.provision.eva.EvaClient;
1120
import invite.provision.graph.GraphClient;
1221
import invite.provision.graph.GraphResponse;
13-
import invite.provision.scim.*;
22+
import invite.provision.scim.GroupPatchRequest;
23+
import invite.provision.scim.GroupRequest;
24+
import invite.provision.scim.GroupURN;
25+
import invite.provision.scim.Member;
26+
import invite.provision.scim.Operation;
27+
import invite.provision.scim.OperationType;
28+
import invite.provision.scim.UserRequest;
1429
import invite.repository.RemoteProvisionedGroupRepository;
1530
import invite.repository.RemoteProvisionedUserRepository;
31+
import invite.repository.RoleRepository;
1632
import invite.repository.UserRoleRepository;
17-
import com.fasterxml.jackson.databind.ObjectMapper;
18-
import crypto.KeyStore;
1933
import lombok.Getter;
2034
import lombok.SneakyThrows;
21-
import okhttp3.OkHttpClient;
2235
import org.apache.commons.logging.Log;
2336
import org.apache.commons.logging.LogFactory;
2437
import org.springframework.beans.factory.annotation.Autowired;
2538
import org.springframework.beans.factory.annotation.Value;
2639
import org.springframework.core.ParameterizedTypeReference;
27-
import org.springframework.http.*;
28-
import org.springframework.http.client.OkHttp3ClientHttpRequestFactory;
40+
import org.springframework.http.HttpHeaders;
41+
import org.springframework.http.HttpMethod;
42+
import org.springframework.http.HttpStatus;
43+
import org.springframework.http.HttpStatusCode;
44+
import org.springframework.http.MediaType;
45+
import org.springframework.http.RequestEntity;
46+
import org.springframework.http.ResponseEntity;
47+
import org.springframework.http.client.ClientHttpRequestInterceptor;
48+
import org.springframework.http.client.JdkClientHttpRequestFactory;
49+
import org.springframework.retry.support.RetryTemplate;
2950
import org.springframework.stereotype.Service;
3051
import org.springframework.util.StringUtils;
3152
import org.springframework.web.client.RestClientException;
3253
import org.springframework.web.client.RestTemplate;
3354

3455
import java.net.URI;
35-
import java.util.*;
36-
import java.util.concurrent.TimeUnit;
56+
import java.time.Duration;
57+
import java.util.ArrayList;
58+
import java.util.Collection;
59+
import java.util.Collections;
60+
import java.util.HashSet;
61+
import java.util.List;
62+
import java.util.Map;
63+
import java.util.Optional;
64+
import java.util.Set;
3765
import java.util.concurrent.atomic.AtomicReference;
3866
import java.util.stream.Collectors;
67+
import java.util.stream.Stream;
3968

4069
@Service
4170
@SuppressWarnings("unchecked")
4271
public class ProvisioningServiceDefault implements ProvisioningService {
4372

73+
private final RoleRepository roleRepository;
74+
4475
private enum APIType {
45-
USER_API("Users"), GROUP_API("Groups");
76+
USER_API("Users"), GROUP_API("Groups");
4677

4778
@Getter
4879
private final String display;
@@ -82,7 +113,7 @@ public ProvisioningServiceDefault(UserRoleRepository userRoleRepository,
82113
EduID eduID,
83114
@Value("${voot.group_urn_domain}") String groupUrnPrefix,
84115
@Value("${config.eduid-idp-schac-home-organization}") String eduidIdpSchacHomeOrganization,
85-
@Value("${config.server-url}") String serverBaseURL) {
116+
@Value("${config.server-url}") String serverBaseURL, RoleRepository roleRepository) {
86117
this.userRoleRepository = userRoleRepository;
87118
this.remoteProvisionedUserRepository = remoteProvisionedUserRepository;
88119
this.remoteProvisionedGroupRepository = remoteProvisionedGroupRepository;
@@ -93,11 +124,11 @@ public ProvisioningServiceDefault(UserRoleRepository userRoleRepository,
93124
this.eduID = eduID;
94125
this.graphClient = new GraphClient(serverBaseURL, eduidIdpSchacHomeOrganization, keyStore, objectMapper);
95126
this.evaClient = new EvaClient(keyStore, remoteProvisionedUserRepository);
96-
// Otherwise, we can't use method PATCH
97-
OkHttpClient.Builder builder = new OkHttpClient.Builder();
98-
builder.connectTimeout(1, TimeUnit.MINUTES);
99-
builder.retryOnConnectionFailure(true);
100-
restTemplate.setRequestFactory(new OkHttp3ClientHttpRequestFactory(builder.build()));
127+
// Using JdkClientHttpRequestFactory (available in Spring 6.1+)
128+
JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory();
129+
requestFactory.setReadTimeout(Duration.ofMinutes(1));
130+
restTemplate.setRequestFactory(requestFactory);
131+
this.roleRepository = roleRepository;
101132
}
102133

103134
@Override
@@ -201,6 +232,63 @@ public void deleteUserRequest(User user) {
201232

202233
List<Provisioning> provisionings = getProvisionings(user);
203234
//Delete the user to all provisionings in Manage where the user is known
235+
deprovisionUser(user, provisionings);
236+
}
237+
238+
@Override
239+
public void deleteUserRequest(User user, UserRole userRole) {
240+
//First send update role request
241+
this.updateGroupRequest(userRole, OperationType.Remove);
242+
/*
243+
* We first need a List all provisionings for the user#userRole, and then we need to remove the provisiongs
244+
* from that List that are in use by other user#userRoles, and those are the provisionings which we need to delete
245+
*/
246+
List<Provisioning> userRoleProvisionings = getProvisioningsUserRole(user, userRole);
247+
List<String> otherProvisioningIdentifiers = user.getUserRoles().stream()
248+
.filter(otherUserRole -> !otherUserRole.getId().equals(userRole.getId()))
249+
.map(otherUserRole -> getProvisioningsUserRole(user, userRole))
250+
.flatMap(Collection::stream)
251+
.map(Provisioning::getId)
252+
.toList();
253+
List<Provisioning> provisionings = userRoleProvisionings.stream()
254+
.filter(provisioning -> !otherProvisioningIdentifiers.contains(provisioning.getId()))
255+
.toList();
256+
//Delete the user to the not used anymore provisionings in Manage
257+
deprovisionUser(user, provisionings);
258+
}
259+
260+
@Override
261+
public void deleteUserRequest(Role role) {
262+
List<String> manageIdentifiers = getManageIdentifiers(role);
263+
List<Provisioning> allRoleProvisionings = manage.provisioning(manageIdentifiers).stream()
264+
.map(Provisioning::new)
265+
.toList();
266+
/*
267+
* We can't deprovision all users of the Role in each provisioning, as they might be in use in other provisioned
268+
* roles. We need all provisionings of the Role, but we need to check for each user which provisionings needs to
269+
* be excluded from the deprovision.
270+
*/
271+
List<UserRole> userRoles = userRoleRepository.findByRole(role);
272+
userRoles.forEach(userRole -> {
273+
User user = userRole.getUser();
274+
List<ManageIdentifier> otherManageIdentifiers = user.getUserRoles().stream()
275+
.filter(otherUserRole -> !otherUserRole.getId().equals(userRole.getId()))
276+
.map(otherUserRole -> user.manageIdentifierSet(userRole))
277+
.flatMap(Collection::stream)
278+
.toList();
279+
// Provisionings that are used by any other userRoles are filtered out
280+
List<Provisioning> provisionings = allRoleProvisionings.stream()
281+
.filter(provisioning -> provisioning.getRemoteApplications().stream()
282+
.noneMatch(otherManageIdentifiers::contains))
283+
.toList();
284+
//Delete the user to the not used anymore provisionings in Manage
285+
deprovisionUser(user, provisionings);
286+
});
287+
288+
289+
}
290+
291+
private void deprovisionUser(User user, List<Provisioning> provisionings) {
204292
provisionings.forEach(provisioning -> {
205293
Optional<RemoteProvisionedUser> provisionedUserOptional = this.remoteProvisionedUserRepository
206294
.findByManageProvisioningIdAndUser(provisioning.getId(), user);
@@ -465,6 +553,12 @@ private List<Provisioning> getProvisionings(User user) {
465553
return manage.provisioning(identifiers).stream().map(Provisioning::new).toList();
466554
}
467555

556+
private List<Provisioning> getProvisioningsUserRole(User user, UserRole userRole) {
557+
Set<ManageIdentifier> manageIdentifiers = user.manageIdentifierSet(userRole);
558+
List<String> identifiers = manageIdentifiers.stream().map(ManageIdentifier::manageId).toList();
559+
return manage.provisioning(identifiers).stream().map(Provisioning::new).toList();
560+
}
561+
468562
private List<Provisioning> getProvisionings(Role role) {
469563
List<String> manageIdentifiers = getManageIdentifiers(role);
470564
return manage.provisioning(manageIdentifiers).stream().map(Provisioning::new).toList();

server/src/main/java/invite/repository/RoleRepository.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,12 @@
55
import org.springframework.data.domain.Pageable;
66
import org.springframework.data.domain.Sort;
77
import org.springframework.data.jpa.repository.JpaRepository;
8+
import org.springframework.data.jpa.repository.Modifying;
89
import org.springframework.data.jpa.repository.Query;
910
import org.springframework.data.jpa.repository.QueryRewriter;
1011
import org.springframework.stereotype.Repository;
12+
import org.springframework.transaction.annotation.Isolation;
13+
import org.springframework.transaction.annotation.Transactional;
1114

1215
import java.util.List;
1316
import java.util.Map;
@@ -16,6 +19,12 @@
1619
@Repository
1720
public interface RoleRepository extends JpaRepository<Role, Long>, QueryRewriter {
1821

22+
@Modifying
23+
@Query(value = "DELETE FROM roles WHERE id = ?1", nativeQuery = true)
24+
@Transactional(isolation = Isolation.SERIALIZABLE)
25+
void deleteRoleById(Long id);
26+
27+
1928
@Query(value = "SELECT *, (SELECT COUNT(*) FROM user_roles ur WHERE ur.role_id=r.id) as userRoleCount " +
2029
"FROM roles r WHERE MATCH (name, description) against (?1 IN BOOLEAN MODE) AND id > 0 LIMIT ?2",
2130
nativeQuery = true)

0 commit comments

Comments
 (0)