Skip to content

Commit d1aae52

Browse files
authored
feat(auth): Add bulk get/delete methods (#400)
This PR allows callers to retrieve a list of users by unique identifier (uid, email, phone, federated provider uid) as well as to delete a list of users. RELEASE NOTE: Added get_users() and delete_users() APIs for retrieving and deleting user accounts in bulk.
1 parent 96b82c0 commit d1aae52

File tree

10 files changed

+907
-5
lines changed

10 files changed

+907
-5
lines changed

firebase_admin/_auth_client.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from firebase_admin import _auth_utils
2222
from firebase_admin import _http_client
2323
from firebase_admin import _token_gen
24+
from firebase_admin import _user_identifier
2425
from firebase_admin import _user_import
2526
from firebase_admin import _user_mgt
2627

@@ -182,6 +183,56 @@ def get_user_by_phone_number(self, phone_number):
182183
response = self._user_manager.get_user(phone_number=phone_number)
183184
return _user_mgt.UserRecord(response)
184185

186+
def get_users(self, identifiers):
187+
"""Gets the user data corresponding to the specified identifiers.
188+
189+
There are no ordering guarantees; in particular, the nth entry in the
190+
result list is not guaranteed to correspond to the nth entry in the input
191+
parameters list.
192+
193+
A maximum of 100 identifiers may be supplied. If more than 100
194+
identifiers are supplied, this method raises a `ValueError`.
195+
196+
Args:
197+
identifiers (list[Identifier]): A list of ``Identifier`` instances used
198+
to indicate which user records should be returned. Must have <= 100
199+
entries.
200+
201+
Returns:
202+
GetUsersResult: A ``GetUsersResult`` instance corresponding to the
203+
specified identifiers.
204+
205+
Raises:
206+
ValueError: If any of the identifiers are invalid or if more than 100
207+
identifiers are specified.
208+
"""
209+
response = self._user_manager.get_users(identifiers=identifiers)
210+
211+
def _matches(identifier, user_record):
212+
if isinstance(identifier, _user_identifier.UidIdentifier):
213+
return identifier.uid == user_record.uid
214+
if isinstance(identifier, _user_identifier.EmailIdentifier):
215+
return identifier.email == user_record.email
216+
if isinstance(identifier, _user_identifier.PhoneIdentifier):
217+
return identifier.phone_number == user_record.phone_number
218+
if isinstance(identifier, _user_identifier.ProviderIdentifier):
219+
return next((
220+
True
221+
for user_info in user_record.provider_data
222+
if identifier.provider_id == user_info.provider_id
223+
and identifier.provider_uid == user_info.uid
224+
), False)
225+
raise TypeError("Unexpected type: {}".format(type(identifier)))
226+
227+
def _is_user_found(identifier, user_records):
228+
return any(_matches(identifier, user_record) for user_record in user_records)
229+
230+
users = [_user_mgt.UserRecord(user) for user in response]
231+
not_found = [
232+
identifier for identifier in identifiers if not _is_user_found(identifier, users)]
233+
234+
return _user_mgt.GetUsersResult(users=users, not_found=not_found)
235+
185236
def list_users(self, page_token=None, max_results=_user_mgt.MAX_LIST_USERS_RESULTS):
186237
"""Retrieves a page of user accounts from a Firebase project.
187238
@@ -306,6 +357,33 @@ def delete_user(self, uid):
306357
"""
307358
self._user_manager.delete_user(uid)
308359

360+
def delete_users(self, uids):
361+
"""Deletes the users specified by the given identifiers.
362+
363+
Deleting a non-existing user does not generate an error (the method is
364+
idempotent.) Non-existing users are considered to be successfully
365+
deleted and are therefore included in the
366+
`DeleteUserResult.success_count` value.
367+
368+
A maximum of 1000 identifiers may be supplied. If more than 1000
369+
identifiers are supplied, this method raises a `ValueError`.
370+
371+
Args:
372+
uids: A list of strings indicating the uids of the users to be deleted.
373+
Must have <= 1000 entries.
374+
375+
Returns:
376+
DeleteUsersResult: The total number of successful/failed deletions, as
377+
well as the array of errors that correspond to the failed
378+
deletions.
379+
380+
Raises:
381+
ValueError: If any of the identifiers are invalid or if more than 1000
382+
identifiers are specified.
383+
"""
384+
result = self._user_manager.delete_users(uids, force_delete=True)
385+
return _user_mgt.DeleteUsersResult(result, len(uids))
386+
309387
def import_users(self, users, hash_alg=None):
310388
"""Imports the specified list of users into Firebase Auth.
311389

firebase_admin/_auth_utils.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,15 @@ def validate_provider_id(provider_id, required=True):
136136
'string.'.format(provider_id))
137137
return provider_id
138138

139+
def validate_provider_uid(provider_uid, required=True):
140+
if provider_uid is None and not required:
141+
return None
142+
if not isinstance(provider_uid, str) or not provider_uid:
143+
raise ValueError(
144+
'Invalid provider UID: "{0}". Provider UID must be a non-empty '
145+
'string.'.format(provider_uid))
146+
return provider_uid
147+
139148
def validate_photo_url(photo_url, required=False):
140149
"""Parses and validates the given URL string."""
141150
if photo_url is None and not required:

firebase_admin/_rfc3339.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# Copyright 2020 Google Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Parse RFC3339 date strings"""
16+
17+
from datetime import datetime, timezone
18+
import re
19+
20+
def parse_to_epoch(datestr):
21+
"""Parse an RFC3339 date string and return the number of seconds since the
22+
epoch (as a float).
23+
24+
In particular, this method is meant to parse the strings returned by the
25+
JSON mapping of protobuf google.protobuf.timestamp.Timestamp instances:
26+
https://github.com/protocolbuffers/protobuf/blob/4cf5bfee9546101d98754d23ff378ff718ba8438/src/google/protobuf/timestamp.proto#L99
27+
28+
This method has microsecond precision; nanoseconds will be truncated.
29+
30+
Args:
31+
datestr: A string in RFC3339 format.
32+
Returns:
33+
Float: The number of seconds since the Unix epoch.
34+
Raises:
35+
ValueError: Raised if the `datestr` is not a valid RFC3339 date string.
36+
"""
37+
return _parse_to_datetime(datestr).timestamp()
38+
39+
40+
def _parse_to_datetime(datestr):
41+
"""Parse an RFC3339 date string and return a python datetime instance.
42+
43+
Args:
44+
datestr: A string in RFC3339 format.
45+
Returns:
46+
datetime: The corresponding `datetime` (with timezone information).
47+
Raises:
48+
ValueError: Raised if the `datestr` is not a valid RFC3339 date string.
49+
"""
50+
# If more than 6 digits appear in the fractional seconds position, truncate
51+
# to just the most significant 6. (i.e. we only have microsecond precision;
52+
# nanos are truncated.)
53+
datestr_modified = re.sub(r'(\.\d{6})\d*', r'\1', datestr)
54+
55+
# This format is the one we actually expect to occur from our backend. The
56+
# others are only present because the spec says we *should* accept them.
57+
try:
58+
return datetime.strptime(
59+
datestr_modified, '%Y-%m-%dT%H:%M:%S.%fZ'
60+
).replace(tzinfo=timezone.utc)
61+
except ValueError:
62+
pass
63+
64+
try:
65+
return datetime.strptime(
66+
datestr_modified, '%Y-%m-%dT%H:%M:%SZ'
67+
).replace(tzinfo=timezone.utc)
68+
except ValueError:
69+
pass
70+
71+
# Note: %z parses timezone offsets, but requires the timezone offset *not*
72+
# include a separating ':'. As of python 3.7, this was relaxed.
73+
# TODO(rsgowman): Once python3.7 becomes our floor, we can drop the regex
74+
# replacement.
75+
datestr_modified = re.sub(r'(\d\d):(\d\d)$', r'\1\2', datestr_modified)
76+
77+
try:
78+
return datetime.strptime(datestr_modified, '%Y-%m-%dT%H:%M:%S.%f%z')
79+
except ValueError:
80+
pass
81+
82+
try:
83+
return datetime.strptime(datestr_modified, '%Y-%m-%dT%H:%M:%S%z')
84+
except ValueError:
85+
pass
86+
87+
raise ValueError('time data {0} does not match RFC3339 format'.format(datestr))

firebase_admin/_user_identifier.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
# Copyright 2020 Google Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Classes to uniquely identify a user."""
16+
17+
from firebase_admin import _auth_utils
18+
19+
class UserIdentifier:
20+
"""Identifies a user to be looked up."""
21+
22+
23+
class UidIdentifier(UserIdentifier):
24+
"""Used for looking up an account by uid.
25+
26+
See ``auth.get_user()``.
27+
"""
28+
29+
def __init__(self, uid):
30+
"""Constructs a new `UidIdentifier` object.
31+
32+
Args:
33+
uid: A user ID string.
34+
"""
35+
self._uid = _auth_utils.validate_uid(uid, required=True)
36+
37+
@property
38+
def uid(self):
39+
return self._uid
40+
41+
42+
class EmailIdentifier(UserIdentifier):
43+
"""Used for looking up an account by email.
44+
45+
See ``auth.get_user()``.
46+
"""
47+
48+
def __init__(self, email):
49+
"""Constructs a new `EmailIdentifier` object.
50+
51+
Args:
52+
email: A user email address string.
53+
"""
54+
self._email = _auth_utils.validate_email(email, required=True)
55+
56+
@property
57+
def email(self):
58+
return self._email
59+
60+
61+
class PhoneIdentifier(UserIdentifier):
62+
"""Used for looking up an account by phone number.
63+
64+
See ``auth.get_user()``.
65+
"""
66+
67+
def __init__(self, phone_number):
68+
"""Constructs a new `PhoneIdentifier` object.
69+
70+
Args:
71+
phone_number: A phone number string.
72+
"""
73+
self._phone_number = _auth_utils.validate_phone(phone_number, required=True)
74+
75+
@property
76+
def phone_number(self):
77+
return self._phone_number
78+
79+
80+
class ProviderIdentifier(UserIdentifier):
81+
"""Used for looking up an account by provider.
82+
83+
See ``auth.get_user()``.
84+
"""
85+
86+
def __init__(self, provider_id, provider_uid):
87+
"""Constructs a new `ProviderIdentifier` object.
88+
89+
  Args:
90+
    provider_id: A provider ID string.
91+
    provider_uid: A provider UID string.
92+
"""
93+
self._provider_id = _auth_utils.validate_provider_id(provider_id, required=True)
94+
self._provider_uid = _auth_utils.validate_provider_uid(
95+
provider_uid, required=True)
96+
97+
@property
98+
def provider_id(self):
99+
return self._provider_id
100+
101+
@property
102+
def provider_uid(self):
103+
return self._provider_uid

firebase_admin/_user_import.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -472,7 +472,12 @@ def standard_scrypt(cls, memory_cost, parallelization, block_size, derived_key_l
472472

473473

474474
class ErrorInfo:
475-
"""Represents an error encountered while importing an ``ImportUserRecord``."""
475+
"""Represents an error encountered while performing a batch operation such
476+
as importing users or deleting multiple user accounts.
477+
"""
478+
# TODO(rsgowman): This class used to be specific to importing users (hence
479+
# it's home in _user_import.py). It's now also used by bulk deletion of
480+
# users. Move this to a more common location.
476481

477482
def __init__(self, error):
478483
self._index = error['index']

0 commit comments

Comments
 (0)