diff --git a/server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java b/server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java index 48cb30a2e..883979b8f 100644 --- a/server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java +++ b/server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java @@ -1020,6 +1020,60 @@ public ResponseEntity revokePublisherTokens( } } + @PostMapping( + path = "/api/publisher/{provider}/{username}/delete", + produces = MediaType.APPLICATION_JSON_VALUE + ) + @CrossOrigin + @Operation(summary = "Forget a user in response to a data-protection erasure request") + @MutatingOperation + @ApiResponse( + responseCode = "200", + description = "A success message is returned in JSON format", + content = @Content(schema = @Schema(implementation = ResultJson.class)) + ) + @ApiResponse( + responseCode = "403", + description = "An administration token is required", + content = @Content(schema = @Schema(implementation = ResultJson.class)) + ) + @ApiResponse( + responseCode = "404", + description = "User not found", + content = @Content() + ) + public ResponseEntity forgetUser( + @PathVariable @Parameter(description = "Authentication provider", example = "github") String provider, + @PathVariable @Parameter(description = "Provider-specific username") String username, + @RequestParam(value = "token") @Parameter(description = "A personal access token") String tokenValue + ) { + try { + var adminUser = admins.checkAdminUser(tokenValue); + var result = admins.forgetUser(provider, username, adminUser); + return ResponseEntity.ok(result); + } catch (ErrorResultException exc) { + return exc.toResponseEntity(); + } + } + + @PostMapping( + path = "/publisher/{provider}/{authId}/delete", + produces = MediaType.APPLICATION_JSON_VALUE + ) + @MutatingOperation + public ResponseEntity forgetUser( + @PathVariable String provider, + @PathVariable String authId + ) { + try { + var adminUser = admins.checkAdminUser(); + var result = admins.forgetUser(provider, authId, adminUser); + return ResponseEntity.ok(result); + } catch (ErrorResultException exc) { + return exc.toResponseEntity(); + } + } + @GetMapping( path = "/settings", produces = MediaType.APPLICATION_JSON_VALUE diff --git a/server/src/main/java/org/eclipse/openvsx/admin/AdminService.java b/server/src/main/java/org/eclipse/openvsx/admin/AdminService.java index 825ed52c2..3ac265508 100644 --- a/server/src/main/java/org/eclipse/openvsx/admin/AdminService.java +++ b/server/src/main/java/org/eclipse/openvsx/admin/AdminService.java @@ -514,6 +514,109 @@ public ResultJson revokePublisherTokens(String provider, String loginName, UserD return result; } + /** + * Forget a user in line with a data-protection (GDPR) erasure request. + * + *

The user record is anonymised in place rather than deleted, so that retained content + * (extension reviews, security scan and file decisions, and audit logs) keeps referring to a + * row that no longer holds any personal data. Extensions in namespaces where the user was the + * sole member are unpublished but kept in the database and storage, so people who have already + * installed them are unaffected. + * + * @param provider the authentication provider the user belongs to + * @param username the provider-specific username of the user to forget + * @param admin the administrator performing the erasure + */ + @Transactional(rollbackOn = ErrorResultException.class) + public ResultJson forgetUser(String provider, String username, UserData admin) { + var user = repositories.findUserByLoginName(provider, username); + if (user == null) { + throw new ErrorResultException(userNotFoundMessage(provider + "/" + username), HttpStatus.NOT_FOUND); + } + + // Send a DELETE request to the Eclipse publisher agreement API. Guarded so that + // instances without Eclipse integration are unaffected. + if (eclipse.isActive() && user.getEclipsePersonId() != null) { + eclipse.revokePublisherAgreement(user, admin); + } + + // Handle namespace memberships. Where the user is the sole member, unpublish the + // namespace's extensions (deactivate every version, which deactivates the extension and + // drops it from search) but keep them in the database for existing installs. Where other + // members remain, only the membership is removed and the extensions stay active. + var unpublishedExtensionCount = 0; + var removedMembershipCount = 0; + for (var membership : repositories.findMemberships(user).toList()) { + var namespace = membership.getNamespace(); + var soleMember = repositories.findMemberships(namespace).toList().size() <= 1; + if (soleMember) { + for (var extension : repositories.findActiveExtensions(namespace).toList()) { + var deactivated = false; + for (var version : repositories.findVersions(extension)) { + if (version.isActive()) { + version.setActive(false); + deactivated = true; + } + } + if (deactivated) { + extensions.updateExtension(extension); + unpublishedExtensionCount++; + } + } + } + + users.removeNamespaceMember(namespace, user); + removedMembershipCount++; + search.updateSearchEntries(repositories.findActiveExtensions(namespace).toList()); + } + + // Remove customer memberships. The customer and its rate-limit tokens are organisation-level + // and shared, so they are retained. + var removedCustomerMembershipCount = 0; + for (var customerMembership : repositories.findCustomerMemberships(user).toList()) { + entityManager.remove(customerMembership); + removedCustomerMembershipCount++; + } + + // Personal access tokens. Delete tokens that no retained extension version references; + // scrub and deactivate the rest so retained versions still resolve a publisher. + var deletedTokenCount = 0; + var scrubbedTokenCount = 0; + for (var token : repositories.findAccessTokens(user).toList()) { + if (repositories.countVersionsByAccessToken(token) == 0) { + entityManager.remove(token); + deletedTokenCount++; + } else { + token.setActive(false); + token.setValue(null); + token.setDescription(null); + scrubbedTokenCount++; + } + } + + // Anonymise the user record in place. Reviews, scan and file decisions, and audit logs + // keep referencing this row, which no longer holds any personal data. + var tombstoneLogin = "deleted-user-" + user.getId(); + user.setLoginName(tombstoneLogin); + user.setFullName(null); + user.setEmail(null); + user.setAvatarUrl(null); + user.setAuthId(null); + user.setProviderUrl(null); + user.setEclipsePersonId(null); + user.setEclipseToken(null); + user.setRole(null); + + // The success message deliberately contains no personal data, only the tombstone id and counts. + var result = ResultJson.success("Forgot user " + tombstoneLogin + + ": unpublished " + unpublishedExtensionCount + " extensions, removed " + + removedMembershipCount + " namespace memberships, removed " + + removedCustomerMembershipCount + " customer memberships, deleted " + + deletedTokenCount + " tokens, scrubbed " + scrubbedTokenCount + " tokens."); + logs.logAction(admin, result); + return result; + } + public UserData checkAdminUser() { return checkAdminUser(users.findLoggedInUser()); } diff --git a/server/src/main/java/org/eclipse/openvsx/repositories/ExtensionVersionRepository.java b/server/src/main/java/org/eclipse/openvsx/repositories/ExtensionVersionRepository.java index 8bbe592e1..1ee0023b1 100644 --- a/server/src/main/java/org/eclipse/openvsx/repositories/ExtensionVersionRepository.java +++ b/server/src/main/java/org/eclipse/openvsx/repositories/ExtensionVersionRepository.java @@ -55,6 +55,8 @@ Streamable findByVersionAndExtensionNameIgnoreCaseAndExtension Streamable findByPublishedWithAndActive(PersonalAccessToken publishedWith, boolean active); + long countByPublishedWith(PersonalAccessToken publishedWith); + Streamable findByPublishedWithUserAndActive(UserData user, boolean active); Streamable findAll(); diff --git a/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java b/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java index 027c8bfb3..1b3bed6ff 100644 --- a/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java +++ b/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java @@ -346,6 +346,10 @@ public Streamable findVersionsByAccessToken(PersonalAccessToke return extensionVersionRepo.findByPublishedWithAndActive(publishedWith, active); } + public long countVersionsByAccessToken(PersonalAccessToken publishedWith) { + return extensionVersionRepo.countByPublishedWith(publishedWith); + } + public Streamable findVersionsByUser(UserData user, boolean active) { return extensionVersionRepo.findByPublishedWithUserAndActive(user, active); } diff --git a/server/src/main/java/org/eclipse/openvsx/repositories/UserDataRepository.java b/server/src/main/java/org/eclipse/openvsx/repositories/UserDataRepository.java index 52f957203..92409b474 100644 --- a/server/src/main/java/org/eclipse/openvsx/repositories/UserDataRepository.java +++ b/server/src/main/java/org/eclipse/openvsx/repositories/UserDataRepository.java @@ -19,6 +19,8 @@ public interface UserDataRepository extends Repository { UserData findByProviderAndLoginName(String provider, String loginName); + UserData findByProviderAndAuthId(String provider, String authId); + Page findByLoginNameStartingWith(String loginNameStart, Pageable page); long count(); diff --git a/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java b/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java index 8e33347dc..5fa803940 100644 --- a/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java +++ b/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java @@ -56,6 +56,7 @@ import org.eclipse.openvsx.eclipse.EclipseService; import org.eclipse.openvsx.eclipse.EclipseTokenService; import org.eclipse.openvsx.entities.AdminStatistics; +import org.eclipse.openvsx.entities.CustomerMembership; import org.eclipse.openvsx.entities.Extension; import org.eclipse.openvsx.entities.ExtensionReview; import org.eclipse.openvsx.entities.ExtensionVersion; @@ -1040,6 +1041,189 @@ void testRevokeAccessTokens() throws Exception { .andExpect(content().json(successJson("Deactivated 2 tokens of user github/test."))); } + @Test + void testForgetUserNotLoggedIn() throws Exception { + mockMvc.perform(post("/admin/publisher/{provider}/{authId}/delete", "github", "12345") + .with(csrf().asHeader())) + .andExpect(status().isForbidden()); + } + + @Test + void testForgetUserNotAdmin() throws Exception { + mockNormalUser(); + mockMvc.perform(post("/admin/publisher/{provider}/{authId}/delete", "github", "12345") + .with(user("test_user")) + .with(csrf().asHeader())) + .andExpect(status().isForbidden()); + } + + @Test + void testForgetUserWithInvalidToken() throws Exception { + var token = mockNonAdminToken(); + mockMvc.perform(post("/admin/api/publisher/{provider}/{authId}/delete?token={username}", "github", "12345", token.getValue())) + .andExpect(status().isForbidden()); + } + + @Test + void testForgetUserNotFound() throws Exception { + var token = mockAdminToken(); + var loginName = "test_admin"; + Mockito.when(repositories.findUserByLoginName("github", loginName)).thenReturn(null); + mockMvc.perform(post("/admin/api/publisher/{provider}/{username}/delete?token={token}", "github", loginName, token.getValue())) + .andExpect(status().isNotFound()); + } + + @Test + void testForgetUserWithToken() throws Exception { + var token = mockAdminToken(); + var user = mockForgettableUser(); + + // The user is the sole member of a namespace, so its extension must be unpublished but retained. + var namespace = new Namespace(); + namespace.setName("janedoe-ns"); + var membership = new NamespaceMembership(); + membership.setNamespace(namespace); + membership.setUser(user); + membership.setRole(NamespaceMembership.ROLE_OWNER); + Mockito.when(repositories.findMemberships(user)).thenReturn(Streamable.of(membership)); + Mockito.when(repositories.findMemberships(namespace)).thenReturn(Streamable.of(membership)); + Mockito.when(repositories.findMembership(user, namespace)).thenReturn(membership); + + var extension = new Extension(); + extension.setName("ext"); + extension.setNamespace(namespace); + extension.setActive(true); + var version = new ExtensionVersion(); + version.setExtension(extension); + version.setActive(true); + extension.getVersions().add(version); + Mockito.when(repositories.findActiveExtensions(namespace)).thenReturn(Streamable.of(extension)); + Mockito.when(repositories.findVersions(extension)).thenReturn(Streamable.of(version)); + + var customerMembership = new CustomerMembership(); + customerMembership.setUser(user); + Mockito.when(repositories.findCustomerMemberships(user)).thenReturn(Streamable.of(customerMembership)); + + // A token not referenced by any retained version must be deleted outright. + var unreferenced = new PersonalAccessToken(); + unreferenced.setUser(user); + unreferenced.setActive(true); + Mockito.when(repositories.findAccessTokens(user)).thenReturn(Streamable.of(unreferenced)); + Mockito.when(repositories.countVersionsByAccessToken(unreferenced)).thenReturn(0L); + + mockMvc.perform(post("/admin/api/publisher/{provider}/{authId}/delete?token={token}", "github", "janedoe", token.getValue())) + .andExpect(status().isOk()) + .andExpect(content().json(successJson("Forgot user deleted-user-7: unpublished 1 extensions, removed 1 namespace memberships, removed 1 customer memberships, deleted 1 tokens, scrubbed 0 tokens."))); + + // The extension and its version are deactivated but kept in the database. + assertThat(version.isActive()).isFalse(); + assertThat(extension.isActive()).isFalse(); + Mockito.verify(entityManager, Mockito.never()).remove(extension); + Mockito.verify(entityManager, Mockito.never()).remove(version); + + // Memberships, the customer membership, and the unreferenced token are removed. + Mockito.verify(entityManager).remove(membership); + Mockito.verify(entityManager).remove(customerMembership); + Mockito.verify(entityManager).remove(unreferenced); + + // The user record is anonymised in place rather than deleted. + Mockito.verify(entityManager, Mockito.never()).remove(user); + assertThat(user.getLoginName()).isEqualTo("deleted-user-7"); + assertThat(user.getFullName()).isNull(); + assertThat(user.getEmail()).isNull(); + assertThat(user.getAvatarUrl()).isNull(); + assertThat(user.getAuthId()).isNull(); + assertThat(user.getProviderUrl()).isNull(); + assertThat(user.getRole()).isNull(); + } + + @Test + void testForgetUserSharedNamespaceKeepsExtensions() throws Exception { + mockAdminUser(); + var user = mockForgettableUser(); + + // A namespace with another member must keep its extensions active. + var namespace = new Namespace(); + namespace.setName("shared-ns"); + var membership = new NamespaceMembership(); + membership.setNamespace(namespace); + membership.setUser(user); + membership.setRole(NamespaceMembership.ROLE_OWNER); + var otherMembership = new NamespaceMembership(); + otherMembership.setNamespace(namespace); + otherMembership.setUser(new UserData()); + otherMembership.setRole(NamespaceMembership.ROLE_OWNER); + Mockito.when(repositories.findMemberships(user)).thenReturn(Streamable.of(membership)); + Mockito.when(repositories.findMemberships(namespace)).thenReturn(Streamable.of(membership, otherMembership)); + Mockito.when(repositories.findMembership(user, namespace)).thenReturn(membership); + + var extension = new Extension(); + extension.setName("ext"); + extension.setNamespace(namespace); + extension.setActive(true); + var version = new ExtensionVersion(); + version.setExtension(extension); + version.setActive(true); + extension.getVersions().add(version); + Mockito.when(repositories.findActiveExtensions(namespace)).thenReturn(Streamable.of(extension)); + + Mockito.when(repositories.findCustomerMemberships(user)).thenReturn(Streamable.empty()); + Mockito.when(repositories.findAccessTokens(user)).thenReturn(Streamable.empty()); + + mockMvc.perform(post("/admin/publisher/{provider}/{authId}/delete", "github", "janedoe") + .with(user("admin_user").authorities(new SimpleGrantedAuthority(("ROLE_ADMIN")))) + .with(csrf().asHeader())) + .andExpect(status().isOk()) + .andExpect(content().json(successJson("Forgot user deleted-user-7: unpublished 0 extensions, removed 1 namespace memberships, removed 0 customer memberships, deleted 0 tokens, scrubbed 0 tokens."))); + + // The shared extension and version stay active; only the membership is removed. + assertThat(version.isActive()).isTrue(); + assertThat(extension.isActive()).isTrue(); + Mockito.verify(entityManager).remove(membership); + Mockito.verify(entityManager, Mockito.never()).remove(extension); + } + + @Test + void testForgetUserScrubsReferencedToken() throws Exception { + var token = mockAdminToken(); + var user = mockForgettableUser(); + Mockito.when(repositories.findMemberships(user)).thenReturn(Streamable.empty()); + Mockito.when(repositories.findCustomerMemberships(user)).thenReturn(Streamable.empty()); + + // A token still referenced by a retained version must be scrubbed and kept, not deleted. + var referenced = new PersonalAccessToken(); + referenced.setUser(user); + referenced.setActive(true); + referenced.setValue("secret-value"); + referenced.setDescription("my token"); + Mockito.when(repositories.findAccessTokens(user)).thenReturn(Streamable.of(referenced)); + Mockito.when(repositories.countVersionsByAccessToken(referenced)).thenReturn(2L); + + mockMvc.perform(post("/admin/api/publisher/{provider}/{authId}/delete?token={token}", "github", "janedoe", token.getValue())) + .andExpect(status().isOk()) + .andExpect(content().json(successJson("Forgot user deleted-user-7: unpublished 0 extensions, removed 0 namespace memberships, removed 0 customer memberships, deleted 0 tokens, scrubbed 1 tokens."))); + + Mockito.verify(entityManager, Mockito.never()).remove(referenced); + assertThat(referenced.isActive()).isFalse(); + assertThat(referenced.getValue()).isNull(); + assertThat(referenced.getDescription()).isNull(); + } + + private UserData mockForgettableUser() { + var user = new UserData(); + user.setId(7); + user.setProvider("github"); + user.setAuthId("12345"); + user.setLoginName("janedoe"); + user.setFullName("Jane Doe"); + user.setEmail("jane@example.com"); + user.setAvatarUrl("https://example.com/avatar.png"); + user.setProviderUrl("https://github.com/janedoe"); + user.setRole(UserData.Role.PRIVILEGED); + Mockito.when(repositories.findUserByLoginName("github", "janedoe")).thenReturn(user); + return user; + } + @Test void testReportUnsupportedMediaType() throws Exception { var token = mockNonAdminToken(); diff --git a/server/src/test/java/org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java b/server/src/test/java/org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java index 60c5a0dd1..b82fdd934 100644 --- a/server/src/test/java/org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java +++ b/server/src/test/java/org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java @@ -237,6 +237,7 @@ void testExecuteQueries() { () -> repositories.findVersion("version", "targetPlatform", "extensionName", "namespace"), () -> repositories.findVersions(extension), () -> repositories.findVersionsByAccessToken(personalAccessToken, true), + () -> repositories.countVersionsByAccessToken(personalAccessToken), () -> repositories.getMaxExtensionDownloadCount(), () -> repositories.getOldestExtensionTimestamp(), () -> repositories.findExtensions(LONG_LIST),