Skip to content

Commit 9f0c4ee

Browse files
committed
Bulk delete users
1 parent f3b8051 commit 9f0c4ee

File tree

6 files changed

+345
-1
lines changed

6 files changed

+345
-1
lines changed
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/*
2+
* Copyright 2020 Google Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.firebase.auth;
18+
19+
import static com.google.common.base.Preconditions.checkArgument;
20+
import static com.google.common.base.Preconditions.checkNotNull;
21+
22+
import com.google.common.collect.ImmutableList;
23+
import com.google.firebase.auth.internal.BatchDeleteResponse;
24+
import com.google.firebase.internal.NonNull;
25+
import java.util.List;
26+
27+
/**
28+
* Represents the result of the {@link FirebaseAuth#deleteUsersAsync(List)} API.
29+
*/
30+
public final class DeleteUsersResult {
31+
32+
private int successCount;
33+
private int failureCount;
34+
private List<ErrorInfo> errors;
35+
36+
DeleteUsersResult(int users, BatchDeleteResponse response) {
37+
ImmutableList.Builder<ErrorInfo> errorsBuilder = ImmutableList.builder();
38+
List<BatchDeleteResponse.ErrorInfo> responseErrors = response.getErrors();
39+
if (responseErrors != null) {
40+
checkArgument(users >= responseErrors.size());
41+
for (BatchDeleteResponse.ErrorInfo error : responseErrors) {
42+
errorsBuilder.add(new ErrorInfo(error.getIndex(), error.getMessage()));
43+
}
44+
}
45+
errors = errorsBuilder.build();
46+
failureCount = errors.size();
47+
successCount = users - errors.size();
48+
}
49+
50+
/**
51+
* Returns the number of users that were deleted successfully (possibly zero). Users that did
52+
* not exist prior to calling deleteUsersAsync() will be considered to be successfully
53+
* deleted.
54+
*/
55+
public int getSuccessCount() {
56+
return successCount;
57+
}
58+
59+
/**
60+
* Returns the number of users that failed to be deleted (possibly zero).
61+
*/
62+
public int getFailureCount() {
63+
return failureCount;
64+
}
65+
66+
/**
67+
* A list of {@link ErrorInfo} instances describing the errors that were encountered during
68+
* the deletion. Length of this list is equal to the return value of
69+
* {@link #getFailureCount()}.
70+
*
71+
* @return A non-null list (possibly empty).
72+
*/
73+
@NonNull
74+
public List<ErrorInfo> getErrors() {
75+
return errors;
76+
}
77+
}

src/main/java/com/google/firebase/auth/FirebaseAuth.java

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -919,6 +919,59 @@ protected Void execute() throws FirebaseAuthException {
919919
};
920920
}
921921

922+
/**
923+
* Deletes the users specified by the given identifiers.
924+
*
925+
* <p>Deleting a non-existing user won't generate an error. (i.e. this method is idempotent.)
926+
* Non-existing users will be considered to be successfully deleted, and will therefore be counted
927+
* in the DeleteUsersResult.getSuccessCount() value.
928+
*
929+
* <p>Only a maximum of 1000 identifiers may be supplied. If more than 1000 identifiers are
930+
* supplied, this method will immediately throw an IllegalArgumentException.
931+
*
932+
* <p>This API is currently rate limited at the server to 1 QPS. If you exceed this, you may get a
933+
* quota exceeded error. Therefore, if you want to delete more than 1000 users, you may need to
934+
* add a delay to ensure you don't go over this limit.
935+
*
936+
* @param uids The uids of the users to be deleted. Must have <= 1000 entries.
937+
* @return The total number of successful/failed deletions, as well as the array of errors that
938+
* correspond to the failed deletions.
939+
* @throw IllegalArgumentException If any of the identifiers are invalid or if more than 1000
940+
* identifiers are specified.
941+
* @throws FirebaseAuthException If an error occurs while deleting users.
942+
*/
943+
public DeleteUsersResult deleteUsers(List<String> uids) throws FirebaseAuthException {
944+
return deleteUsersOp(uids).call();
945+
}
946+
947+
/**
948+
* Similar to {@link #deleteUsers(List)} but performs the operation asynchronously.
949+
*
950+
* @param uids The uids of the users to be deleted. Must have <= 1000 entries.
951+
* @return An {@code ApiFuture} that resolves to the total number of successful/failed
952+
* deletions, as well as the array of errors that correspond to the failed deletions. If an
953+
* error occurs while deleting the user account, the future throws a
954+
* {@link FirebaseAuthException}.
955+
* @throw IllegalArgumentException If any of the identifiers are invalid or if more than 1000
956+
* identifiers are specified.
957+
*/
958+
public ApiFuture<DeleteUsersResult> deleteUsersAsync(List<String> uids) {
959+
return deleteUsersOp(uids).callAsync(firebaseApp);
960+
}
961+
962+
private CallableOperation<DeleteUsersResult, FirebaseAuthException> deleteUsersOp(
963+
final List<String> uids) {
964+
checkNotDestroyed();
965+
checkNotNull(uids, "uids must not be null");
966+
final FirebaseUserManager userManager = getUserManager();
967+
return new CallableOperation<DeleteUsersResult, FirebaseAuthException>() {
968+
@Override
969+
protected DeleteUsersResult execute() throws FirebaseAuthException {
970+
return userManager.deleteUsers(uids);
971+
}
972+
};
973+
}
974+
922975
/**
923976
* Imports the provided list of users into Firebase Auth. At most 1000 users can be imported at a
924977
* time. This operation is optimized for bulk imports and will ignore checks on identifier

src/main/java/com/google/firebase/auth/FirebaseUserManager.java

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,10 @@
3939
import com.google.firebase.ImplFirebaseTrampolines;
4040
import com.google.firebase.auth.UserRecord.CreateRequest;
4141
import com.google.firebase.auth.UserRecord.UpdateRequest;
42+
import com.google.firebase.auth.internal.BatchDeleteResponse;
4243
import com.google.firebase.auth.internal.DownloadAccountResponse;
4344
import com.google.firebase.auth.internal.GetAccountInfoRequest;
4445
import com.google.firebase.auth.internal.GetAccountInfoResponse;
45-
4646
import com.google.firebase.auth.internal.HttpErrorResponse;
4747
import com.google.firebase.auth.internal.UploadAccountResponse;
4848
import com.google.firebase.internal.ApiClientUtils;
@@ -237,6 +237,19 @@ void deleteUser(String uid) throws FirebaseAuthException {
237237
}
238238
}
239239

240+
DeleteUsersResult deleteUsers(List<String> uids) throws FirebaseAuthException {
241+
final Map<String, Object> payload = ImmutableMap.<String, Object>of(
242+
"localIds", uids,
243+
"force", true);
244+
BatchDeleteResponse response = post(
245+
"/accounts:batchDelete", payload, BatchDeleteResponse.class);
246+
if (response == null) {
247+
throw new FirebaseAuthException(INTERNAL_ERROR, "Failed to delete users");
248+
}
249+
250+
return new DeleteUsersResult(uids.size(), response);
251+
}
252+
240253
DownloadAccountResponse listUsers(int maxResults, String pageToken) throws FirebaseAuthException {
241254
ImmutableMap.Builder<String, Object> builder = ImmutableMap.<String, Object>builder()
242255
.put("maxResults", maxResults);
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/*
2+
* Copyright 2020 Google Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.firebase.auth.internal;
18+
19+
import com.google.api.client.util.Key;
20+
import java.util.List;
21+
22+
/**
23+
* Represents the response from identity toolkit for a batch delete request.
24+
*/
25+
public class BatchDeleteResponse {
26+
27+
@Key("errors")
28+
private List<ErrorInfo> errors;
29+
30+
public List<ErrorInfo> getErrors() {
31+
return errors;
32+
}
33+
34+
public static class ErrorInfo {
35+
@Key("index")
36+
private int index;
37+
38+
@Key("message")
39+
private String message;
40+
41+
// A 'localId' field also exists here, but is not currently exposed in the admin sdk.
42+
43+
public int getIndex() {
44+
return index;
45+
}
46+
47+
public String getMessage() {
48+
return message;
49+
}
50+
}
51+
}

src/test/java/com/google/firebase/auth/FirebaseAuthIT.java

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,83 @@ public void testDeleteNonExistingUser() throws Exception {
137137
}
138138
}
139139

140+
@Test
141+
public void testDeleteUsers() throws Exception {
142+
UserRecord user1 = newUserWithParams();
143+
UserRecord user2 = newUserWithParams();
144+
UserRecord user3 = newUserWithParams();
145+
146+
DeleteUsersResult deleteUsersResult = slowDeleteUsersAsync(
147+
ImmutableList.of(user1.getUid(), user2.getUid(), user3.getUid())
148+
).get();
149+
150+
assertEquals(3, deleteUsersResult.getSuccessCount());
151+
assertEquals(0, deleteUsersResult.getFailureCount());
152+
assertTrue(deleteUsersResult.getErrors().isEmpty());
153+
154+
GetUsersResult getUsersResult = auth.getUsersAsync(ImmutableList.<UserIdentifier>of(
155+
new UidIdentifier(user1.getUid()),
156+
new UidIdentifier(user2.getUid()),
157+
new UidIdentifier(user3.getUid())
158+
)).get();
159+
160+
assertTrue(getUsersResult.getUsers().isEmpty());
161+
assertEquals(3, getUsersResult.getNotFound().size());
162+
}
163+
164+
@Test
165+
public void testDeleteExistingAndNonExistingUsers() throws Exception {
166+
UserRecord user1 = newUserWithParams();
167+
168+
DeleteUsersResult deleteUsersResult = slowDeleteUsersAsync(
169+
ImmutableList.of(user1.getUid(), "uid-that-doesnt-exist")
170+
).get();
171+
172+
assertEquals(2, deleteUsersResult.getSuccessCount());
173+
assertEquals(0, deleteUsersResult.getFailureCount());
174+
assertTrue(deleteUsersResult.getErrors().isEmpty());
175+
176+
GetUsersResult getUsersResult = auth.getUsersAsync(ImmutableList.<UserIdentifier>of(
177+
new UidIdentifier(user1.getUid()),
178+
new UidIdentifier("uid-that-doesnt-exist")
179+
)).get();
180+
181+
assertTrue(getUsersResult.getUsers().isEmpty());
182+
assertEquals(2, getUsersResult.getNotFound().size());
183+
}
184+
185+
@Test
186+
public void testDeleteUsersIsIdempotent() throws Exception {
187+
UserRecord user1 = newUserWithParams();
188+
189+
DeleteUsersResult result = slowDeleteUsersAsync(
190+
ImmutableList.of(user1.getUid())
191+
).get();
192+
193+
assertEquals(1, result.getSuccessCount());
194+
assertEquals(0, result.getFailureCount());
195+
assertTrue(result.getErrors().isEmpty());
196+
197+
// Delete the user again, ensuring that everything still counts as a success.
198+
result = slowDeleteUsersAsync(
199+
ImmutableList.of(user1.getUid())
200+
).get();
201+
202+
assertEquals(1, result.getSuccessCount());
203+
assertEquals(0, result.getFailureCount());
204+
assertTrue(result.getErrors().isEmpty());
205+
}
206+
207+
/**
208+
* The batchDelete endpoint is currently rate limited to 1qps. Use this test helper to ensure we
209+
* don't run into quota exceeded errors.
210+
*/
211+
// TODO(rsgowman): When/if the rate limit is relaxed, eliminate this helper.
212+
private ApiFuture<DeleteUsersResult> slowDeleteUsersAsync(List<String> uids) throws Exception {
213+
TimeUnit.SECONDS.sleep(1);
214+
return auth.deleteUsersAsync(uids);
215+
}
216+
140217
@Test
141218
public void testCreateUserWithParams() throws Exception {
142219
RandomUser randomUser = RandomUser.create();
@@ -742,6 +819,10 @@ static RandomUser create() {
742819
}
743820
}
744821

822+
static UserRecord newUserWithParams() throws Exception {
823+
return newUserWithParams(auth);
824+
}
825+
745826
static UserRecord newUserWithParams(FirebaseAuth auth) throws Exception {
746827
// TODO(rsgowman): This function could be used throughout this file (similar to the other
747828
// ports).

src/test/java/com/google/firebase/auth/FirebaseUserManagerTest.java

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,75 @@ public void testDeleteUser() throws Exception {
422422
checkRequestHeaders(interceptor);
423423
}
424424

425+
@Test
426+
public void testDeleteUsersExceeds1000() throws Exception {
427+
FirebaseApp.initializeApp();
428+
List<String> ids = new ArrayList<>();
429+
for (int i = 0; i < 1001; i++) {
430+
ids.add("id" + i);
431+
}
432+
try {
433+
FirebaseAuth.getInstance().deleteUsersAsync(ids);
434+
} catch (IllegalArgumentException expected) {
435+
// expected
436+
}
437+
}
438+
439+
@Test
440+
public void testDeleteUsersInvalidId() throws Exception {
441+
FirebaseApp.initializeApp();
442+
try {
443+
FirebaseAuth.getInstance().deleteUsersAsync(
444+
ImmutableList.of("too long " + Strings.repeat(".", 128)));
445+
} catch (IllegalArgumentException expected) {
446+
// expected
447+
}
448+
}
449+
450+
@Test
451+
public void testDeleteUsersIndexesErrorsCorrectly() throws Exception {
452+
initializeAppForUserManagement((""
453+
+ "{ "
454+
+ " 'errors': [{ "
455+
+ " 'index': 0, "
456+
+ " 'localId': 'uid1', "
457+
+ " 'message': 'NOT_DISABLED : Disable the account before batch deletion.' "
458+
+ " }, { "
459+
+ " 'index': 2, "
460+
+ " 'localId': 'uid3', "
461+
+ " 'message': 'something awful' "
462+
+ " }] "
463+
+ "} "
464+
).replace("'", "\""));
465+
466+
DeleteUsersResult result = FirebaseAuth.getInstance().deleteUsersAsync(ImmutableList.of(
467+
"uid1", "uid2", "uid3", "uid4"
468+
)).get();
469+
470+
assertEquals(2, result.getSuccessCount());
471+
assertEquals(2, result.getFailureCount());
472+
assertEquals(2, result.getErrors().size());
473+
assertEquals(0, result.getErrors().get(0).getIndex());
474+
assertEquals(
475+
"NOT_DISABLED : Disable the account before batch deletion.",
476+
result.getErrors().get(0).getReason());
477+
assertEquals(2, result.getErrors().get(1).getIndex());
478+
assertEquals("something awful", result.getErrors().get(1).getReason());
479+
}
480+
481+
@Test
482+
public void testDeleteUsersSuccess() throws Exception {
483+
initializeAppForUserManagement("{}");
484+
485+
DeleteUsersResult result = FirebaseAuth.getInstance().deleteUsersAsync(ImmutableList.of(
486+
"uid1", "uid2", "uid3"
487+
)).get();
488+
489+
assertEquals(3, result.getSuccessCount());
490+
assertEquals(0, result.getFailureCount());
491+
assertTrue(result.getErrors().isEmpty());
492+
}
493+
425494
@Test
426495
public void testImportUsers() throws Exception {
427496
TestResponseInterceptor interceptor = initializeAppForUserManagement("{}");

0 commit comments

Comments
 (0)