Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java
Original file line number Diff line number Diff line change
Expand Up @@ -1020,6 +1020,60 @@ public ResponseEntity<ResultJson> 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<ResultJson> 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<ResultJson> 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
Expand Down
103 changes: 103 additions & 0 deletions server/src/main/java/org/eclipse/openvsx/admin/AdminService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
* <p>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());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ Streamable<ExtensionVersion> findByVersionAndExtensionNameIgnoreCaseAndExtension

Streamable<ExtensionVersion> findByPublishedWithAndActive(PersonalAccessToken publishedWith, boolean active);

long countByPublishedWith(PersonalAccessToken publishedWith);

Streamable<ExtensionVersion> findByPublishedWithUserAndActive(UserData user, boolean active);

Streamable<ExtensionVersion> findAll();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,10 @@ public Streamable<ExtensionVersion> findVersionsByAccessToken(PersonalAccessToke
return extensionVersionRepo.findByPublishedWithAndActive(publishedWith, active);
}

public long countVersionsByAccessToken(PersonalAccessToken publishedWith) {
return extensionVersionRepo.countByPublishedWith(publishedWith);
}

public Streamable<ExtensionVersion> findVersionsByUser(UserData user, boolean active) {
return extensionVersionRepo.findByPublishedWithUserAndActive(user, active);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ public interface UserDataRepository extends Repository<UserData, Long> {

UserData findByProviderAndLoginName(String provider, String loginName);

UserData findByProviderAndAuthId(String provider, String authId);

Page<UserData> findByLoginNameStartingWith(String loginNameStart, Pageable page);

long count();
Expand Down
184 changes: 184 additions & 0 deletions server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading