Skip to content

Commit a83f66e

Browse files
committed
Add delete_users() bulk deletion method
1 parent aa8207d commit a83f66e

File tree

4 files changed

+263
-0
lines changed

4 files changed

+263
-0
lines changed

firebase_admin/_user_mgt.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,85 @@ def iterate_all(self):
332332
return _UserIterator(self)
333333

334334

335+
class DeleteUsersResult:
336+
"""Represents the result of the ``auth.delete_users()`` API."""
337+
338+
def __init__(self, result, total):
339+
"""Constructs a DeleteUsersResult.
340+
341+
Args:
342+
result: BatchDeleteAccountsResponse: The proto response, wrapped in a
343+
BatchDeleteAccountsResponse instance.
344+
total: integer: Total number of deletion attempts.
345+
"""
346+
errors = result.errors
347+
self._success_count = total - len(errors)
348+
self._failure_count = len(errors)
349+
self._errors = errors
350+
351+
@property
352+
def success_count(self):
353+
"""Returns the number of users that were deleted successfully (possibly
354+
zero).
355+
356+
Users that did not exist prior to calling delete_users() will be
357+
considered to be successfully deleted.
358+
"""
359+
return self._success_count
360+
361+
@property
362+
def failure_count(self):
363+
"""Returns the number of users that failed to be deleted (possibly
364+
zero).
365+
"""
366+
return self._failure_count
367+
368+
@property
369+
def errors(self):
370+
"""A list of ``auth.BatchDeleteErrorInfo`` instances describing the
371+
errors that were encountered during the deletion. Length of this list
372+
is equal to `failure_count`.
373+
"""
374+
return self._errors
375+
376+
377+
class BatchDeleteErrorInfo:
378+
"""Represents an error that occurred while attempting to delete a batch of
379+
users.
380+
"""
381+
382+
def __init__(self, err):
383+
"""Constructs a BatchDeleteErrorInfo instance, corresponding to the
384+
json representing the BatchDeleteErrorInfo proto.
385+
386+
Args:
387+
err: A dictionary with 'index', 'local_id' and 'message' fields,
388+
representing the BatchDeleteErrorInfo dictionary that's
389+
returned by the server.
390+
"""
391+
self.index = err.get('index', 0)
392+
self.local_id = err.get('local_id', "")
393+
self.message = err.get('message', "")
394+
395+
396+
class BatchDeleteAccountsResponse:
397+
"""Represents the results of a delete_users() call."""
398+
399+
def __init__(self, errors=None):
400+
"""Constructs a BatchDeleteAccountsResponse instance, corresponseing to
401+
the json representing the BatchDeleteAccountsResponse proto.
402+
403+
Args:
404+
errors: List of dictionaries, with each dictionary representing a
405+
BatchDeleteErrorInfo instance as returned by the server. None
406+
implies an empty list.
407+
"""
408+
if errors:
409+
self.errors = [BatchDeleteErrorInfo(err) for err in errors]
410+
else:
411+
self.errors = []
412+
413+
335414
class ProviderUserInfo(UserInfo):
336415
"""Contains metadata regarding how a user is known by a particular identity provider."""
337416

@@ -638,6 +717,45 @@ def delete_user(self, uid):
638717
raise _auth_utils.UnexpectedResponseError(
639718
'Failed to delete user: {0}.'.format(uid), http_response=http_resp)
640719

720+
def delete_users(self, uids, force_delete=False):
721+
"""Deletes the users identified by the specified user ids.
722+
723+
Args:
724+
uids: A list of strings indicating the uids of the users to be deleted.
725+
Must have <= 1000 entries.
726+
force_delete: Optional parameter that indicates if users should be
727+
deleted, even if they're not disabled. Defaults to False.
728+
729+
730+
Returns:
731+
BatchDeleteAccountsResponse: Server's proto response, wrapped in a
732+
python object.
733+
734+
Raises:
735+
ValueError: If any of the identifiers are invalid or if more than 1000
736+
identifiers are specified.
737+
"""
738+
if not uids:
739+
return BatchDeleteAccountsResponse()
740+
741+
if len(uids) > 100:
742+
raise ValueError("`uids` paramter must have <= 100 entries.")
743+
for uid in uids:
744+
_auth_utils.validate_uid(uid, required=True)
745+
746+
try:
747+
body, http_resp = self._client.body_and_response(
748+
'post', '/accounts:batchDelete',
749+
json={'localIds': uids, 'force': force_delete})
750+
except requests.exceptions.RequestException as error:
751+
raise _auth_utils.handle_auth_backend_error(error)
752+
else:
753+
if not isinstance(body, dict):
754+
raise _auth_utils.UnexpectedResponseError(
755+
'Unexpected response from server while attempting to delete users.',
756+
http_response=http_resp)
757+
return BatchDeleteAccountsResponse(body.get('errors', []))
758+
641759
def import_users(self, users, hash_alg=None):
642760
"""Imports the given list of users to Firebase Auth."""
643761
try:

firebase_admin/auth.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@
7171
'create_session_cookie',
7272
'create_user',
7373
'delete_user',
74+
'delete_users',
7475
'generate_email_verification_link',
7576
'generate_password_reset_link',
7677
'generate_sign_in_with_email_link',
@@ -90,6 +91,7 @@
9091
ActionCodeSettings = _user_mgt.ActionCodeSettings
9192
CertificateFetchError = _token_gen.CertificateFetchError
9293
DELETE_ATTRIBUTE = _user_mgt.DELETE_ATTRIBUTE
94+
DeleteUsersResult = _user_mgt.DeleteUsersResult
9395
EmailAlreadyExistsError = _auth_utils.EmailAlreadyExistsError
9496
ErrorInfo = _user_import.ErrorInfo
9597
ExpiredIdTokenError = _token_gen.ExpiredIdTokenError
@@ -490,6 +492,41 @@ def delete_user(uid, app=None):
490492
user_manager.delete_user(uid)
491493

492494

495+
def delete_users(uids, force_delete=False, app=None):
496+
"""Deletes the users specified by the given identifiers.
497+
498+
Deleting a non-existing user won't generate an error. (i.e. this method is
499+
idempotent.) Non-existing users will be considered to be successfully
500+
deleted, and will therefore be counted in the
501+
DeleteUserResult.success_count value.
502+
503+
Unless the optional force_delete flag is set to true, only users that are
504+
already disabled will be deleted.
505+
506+
Only a maximum of 1000 identifiers may be supplied. If more than 1000
507+
identifiers are supplied, this method will immediately raise a ValueError.
508+
509+
Args:
510+
uids: A list of strings indicating the uids of the users to be deleted.
511+
Must have <= 1000 entries.
512+
force_delete: Optional parameter that indicates if users should be
513+
deleted, even if they're not disabled. Defaults to False.
514+
app: An App instance (optional).
515+
516+
Returns:
517+
DeleteUsersResult: The total number of successful/failed deletions, as
518+
well as the array of errors that correspond to the failed
519+
deletions.
520+
521+
Raises:
522+
ValueError: If any of the identifiers are invalid or if more than 1000
523+
identifiers are specified.
524+
"""
525+
user_manager = _get_auth_service(app).user_manager
526+
result = user_manager.delete_users(uids, force_delete)
527+
return DeleteUsersResult(result, len(uids))
528+
529+
493530
def import_users(users, hash_alg=None, app=None):
494531
"""Imports the specified list of users into Firebase Auth.
495532

integration/test_auth.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,73 @@ def test_delete_user():
408408
with pytest.raises(auth.UserNotFoundError):
409409
auth.get_user(user.uid)
410410

411+
412+
class TestDeleteUsers:
413+
def test_delete_multiple_disabled_users(self):
414+
uid1 = auth.create_user(disabled=True).uid
415+
uid2 = auth.create_user(disabled=True).uid
416+
uid3 = auth.create_user(disabled=True).uid
417+
418+
delete_users_result = auth.delete_users([uid1, uid2, uid3])
419+
assert delete_users_result.success_count == 3
420+
assert delete_users_result.failure_count == 0
421+
assert len(delete_users_result.errors) == 0
422+
423+
user_records = auth.get_users(
424+
[auth.UidIdentifier(uid1), auth.UidIdentifier(uid2), auth.UidIdentifier(uid3)])
425+
assert len(user_records) == 0
426+
427+
def test_fails_to_delete_enabled_users(self):
428+
uid1 = auth.create_user(disabled=False).uid
429+
uid2 = auth.create_user(disabled=True).uid
430+
uid3 = auth.create_user(disabled=False).uid
431+
432+
try:
433+
delete_users_result = auth.delete_users([uid1, uid2, uid3])
434+
assert delete_users_result.success_count == 1
435+
assert delete_users_result.failure_count == 2
436+
assert len(delete_users_result.errors) == 2
437+
assert delete_users_result.errors[0].index == 0
438+
assert delete_users_result.errors[0].message.startswith('NOT_DISABLED')
439+
assert delete_users_result.errors[1].index == 2
440+
assert delete_users_result.errors[1].message.startswith('NOT_DISABLED')
441+
442+
user_records = auth.get_users(
443+
[auth.UidIdentifier(uid1), auth.UidIdentifier(uid2), auth.UidIdentifier(uid3)])
444+
assert len(user_records) == 2
445+
assert {ur.uid for ur in user_records} == set([uid1, uid3])
446+
447+
finally:
448+
auth.delete_users([uid1, uid3], force_delete=True)
449+
450+
def test_deletes_enabled_users_when_force_is_true(self):
451+
uid1 = auth.create_user(disabled=False).uid
452+
uid2 = auth.create_user(disabled=True).uid
453+
uid3 = auth.create_user(disabled=False).uid
454+
455+
delete_users_result = auth.delete_users([uid1, uid2, uid3], force_delete=True)
456+
assert delete_users_result.success_count == 3
457+
assert delete_users_result.failure_count == 0
458+
assert len(delete_users_result.errors) == 0
459+
460+
user_records = auth.get_users(
461+
[auth.UidIdentifier(uid1), auth.UidIdentifier(uid2), auth.UidIdentifier(uid3)])
462+
assert len(user_records) == 0
463+
464+
def test_is_idempotent(self):
465+
uid = auth.create_user(disabled=True).uid
466+
467+
delete_users_result = auth.delete_users([uid])
468+
assert delete_users_result.success_count == 1
469+
assert delete_users_result.failure_count == 0
470+
471+
# Delete the user again, ensuring that everything still counts as a
472+
# success.
473+
delete_users_result = auth.delete_users([uid])
474+
assert delete_users_result.success_count == 1
475+
assert delete_users_result.failure_count == 0
476+
477+
411478
def test_revoke_refresh_tokens(new_user):
412479
user = auth.get_user(new_user.uid)
413480
old_valid_after = user.tokens_valid_after_timestamp

tests/test_user_mgt.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -653,6 +653,47 @@ def test_delete_user_unexpected_response(self, user_mgt_app):
653653
assert isinstance(excinfo.value, exceptions.UnknownError)
654654

655655

656+
class TestDeleteUsers:
657+
658+
def test_empty_list(self, user_mgt_app):
659+
delete_users_result = auth.delete_users([], app=user_mgt_app)
660+
assert delete_users_result.success_count == 0
661+
assert delete_users_result.failure_count == 0
662+
assert len(delete_users_result.errors) == 0
663+
664+
def test_too_many_identifiers_should_fail(self, user_mgt_app):
665+
ids = ['id' + str(i) for i in range(101)]
666+
with pytest.raises(ValueError):
667+
auth.delete_users(ids, app=user_mgt_app)
668+
669+
def test_invalid_id_should_fail(self, user_mgt_app):
670+
ids = ['too long ' + '.'*128]
671+
with pytest.raises(ValueError):
672+
auth.delete_users(ids, app=user_mgt_app)
673+
674+
def test_should_index_errors_correctly_in_results(self, user_mgt_app):
675+
_instrument_user_manager(user_mgt_app, 200, """{
676+
"errors": [{
677+
"index": 0,
678+
"localId": "uid1",
679+
"message": "NOT_DISABLED : Disable the account before batch deletion."
680+
}, {
681+
"index": 2,
682+
"localId": "uid3",
683+
"message": "something awful"
684+
}]
685+
}""")
686+
687+
delete_users_result = auth.delete_users(['uid1', 'uid2', 'uid3', 'uid4'], app=user_mgt_app)
688+
assert delete_users_result.success_count == 2
689+
assert delete_users_result.failure_count == 2
690+
assert len(delete_users_result.errors) == 2
691+
assert delete_users_result.errors[0].index == 0
692+
assert delete_users_result.errors[0].message.startswith('NOT_DISABLED')
693+
assert delete_users_result.errors[1].index == 2
694+
assert delete_users_result.errors[1].message == 'something awful'
695+
696+
656697
class TestListUsers:
657698

658699
@pytest.mark.parametrize('arg', [None, 'foo', list(), dict(), 0, -1, 1001, False])

0 commit comments

Comments
 (0)