Skip to content

Commit 4c8f86e

Browse files
authored
Added create_tenant() and update_tenant() APIs (#424)
* Create tenant API * Added update tenant API * Added docstring to fix lint error
1 parent 11b5a6b commit 4c8f86e

File tree

3 files changed

+300
-4
lines changed

3 files changed

+300
-4
lines changed

firebase_admin/_auth_utils.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,18 @@ def validate_int(value, label, low=None, high=None):
157157
raise ValueError('{0} must not be larger than {1}.'.format(label, high))
158158
return val_int
159159

160+
def validate_string(value, label):
161+
"""Validates that the given value is a string."""
162+
if not isinstance(value, str):
163+
raise ValueError('Invalid type for {0}: {1}.'.format(label, value))
164+
return value
165+
166+
def validate_boolean(value, label):
167+
"""Validates that the given value is a boolean."""
168+
if not isinstance(value, bool):
169+
raise ValueError('Invalid type for {0}: {1}.'.format(label, value))
170+
return value
171+
160172
def validate_custom_claims(custom_claims, required=False):
161173
"""Validates the specified custom claims.
162174
@@ -192,6 +204,19 @@ def validate_action_type(action_type):
192204
Valid values are {1}'.format(action_type, ', '.join(VALID_EMAIL_ACTION_TYPES)))
193205
return action_type
194206

207+
def build_update_mask(params):
208+
"""Creates an update mask list from the given dictionary."""
209+
mask = []
210+
for key, value in params.items():
211+
if isinstance(value, dict):
212+
child_mask = build_update_mask(value)
213+
for child in child_mask:
214+
mask.append('{0}.{1}'.format(key, child))
215+
else:
216+
mask.append(key)
217+
218+
return sorted(mask)
219+
195220

196221
class UidAlreadyExistsError(exceptions.AlreadyExistsError):
197222
"""The user with the provided uid already exists."""

firebase_admin/tenant_mgt.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,59 @@ def get_tenant(tenant_id, app=None):
5959
return tenant_mgt_service.get_tenant(tenant_id)
6060

6161

62+
def create_tenant(
63+
display_name=None, allow_password_sign_up=None, enable_email_link_sign_in=None, app=None):
64+
"""Creates a new tenant from the given options.
65+
66+
Args:
67+
display_name: Display name string for the new tenant (optional).
68+
allow_password_sign_up: A boolean indicating whether to enable or disable the email sign-in
69+
provider.
70+
enable_email_link_sign_in: A boolean indicating whether to enable or disable email link
71+
sign-in. Disabling this makes the password required for email sign-in.
72+
app: An App instance (optional).
73+
74+
Returns:
75+
Tenant: A Tenant object.
76+
77+
Raises:
78+
ValueError: If any of the given arguments are invalid.
79+
FirebaseError: If an error occurs while creating the tenant.
80+
"""
81+
tenant_mgt_service = _get_tenant_mgt_service(app)
82+
return tenant_mgt_service.create_tenant(
83+
display_name=display_name, allow_password_sign_up=allow_password_sign_up,
84+
enable_email_link_sign_in=enable_email_link_sign_in)
85+
86+
87+
def update_tenant(
88+
tenant_id, display_name=None, allow_password_sign_up=None, enable_email_link_sign_in=None,
89+
app=None):
90+
"""Updates an existing tenant with the given options.
91+
92+
Args:
93+
tenant_id: ID of the tenant to update.
94+
display_name: Display name string for the new tenant (optional).
95+
allow_password_sign_up: A boolean indicating whether to enable or disable the email sign-in
96+
provider.
97+
enable_email_link_sign_in: A boolean indicating whether to enable or disable email link
98+
sign-in. Disabling this makes the password required for email sign-in.
99+
app: An App instance (optional).
100+
101+
Returns:
102+
Tenant: The updated Tenant object.
103+
104+
Raises:
105+
ValueError: If any of the given arguments are invalid.
106+
TenantNotFoundError: If no tenant exists by the given ID.
107+
FirebaseError: If an error occurs while creating the tenant.
108+
"""
109+
tenant_mgt_service = _get_tenant_mgt_service(app)
110+
return tenant_mgt_service.update_tenant(
111+
tenant_id, display_name=display_name, allow_password_sign_up=allow_password_sign_up,
112+
enable_email_link_sign_in=enable_email_link_sign_in)
113+
114+
62115
def delete_tenant(tenant_id, app=None):
63116
"""Deletes the tenant corresponding to the given ``tenant_id``.
64117
@@ -141,6 +194,56 @@ def get_tenant(self, tenant_id):
141194
else:
142195
return Tenant(body)
143196

197+
def create_tenant(
198+
self, display_name=None, allow_password_sign_up=None, enable_email_link_sign_in=None):
199+
"""Creates a new tenant from the given parameters."""
200+
payload = {}
201+
if display_name is not None:
202+
payload['displayName'] = _auth_utils.validate_string(display_name, 'displayName')
203+
if allow_password_sign_up is not None:
204+
payload['allowPasswordSignup'] = _auth_utils.validate_boolean(
205+
allow_password_sign_up, 'allowPasswordSignup')
206+
if enable_email_link_sign_in is not None:
207+
payload['enableEmailLinkSignin'] = _auth_utils.validate_boolean(
208+
enable_email_link_sign_in, 'enableEmailLinkSignin')
209+
210+
try:
211+
body = self.client.body('post', '/tenants', json=payload)
212+
except requests.exceptions.RequestException as error:
213+
raise _auth_utils.handle_auth_backend_error(error)
214+
else:
215+
return Tenant(body)
216+
217+
def update_tenant(
218+
self, tenant_id, display_name=None, allow_password_sign_up=None,
219+
enable_email_link_sign_in=None):
220+
"""Updates the specified tenant with the given parameters."""
221+
if not isinstance(tenant_id, str) or not tenant_id:
222+
raise ValueError('Tenant ID must be a non-empty string.')
223+
224+
payload = {}
225+
if display_name is not None:
226+
payload['displayName'] = _auth_utils.validate_string(display_name, 'displayName')
227+
if allow_password_sign_up is not None:
228+
payload['allowPasswordSignup'] = _auth_utils.validate_boolean(
229+
allow_password_sign_up, 'allowPasswordSignup')
230+
if enable_email_link_sign_in is not None:
231+
payload['enableEmailLinkSignin'] = _auth_utils.validate_boolean(
232+
enable_email_link_sign_in, 'enableEmailLinkSignin')
233+
234+
if not payload:
235+
raise ValueError('At least one parameter must be specified for update.')
236+
237+
url = '/tenants/{0}'.format(tenant_id)
238+
update_mask = ','.join(_auth_utils.build_update_mask(payload))
239+
params = 'updateMask={0}'.format(update_mask)
240+
try:
241+
body = self.client.body('patch', url, json=payload, params=params)
242+
except requests.exceptions.RequestException as error:
243+
raise _auth_utils.handle_auth_backend_error(error)
244+
else:
245+
return Tenant(body)
246+
144247
def delete_tenant(self, tenant_id):
145248
"""Deletes the tenant corresponding to the given ``tenant_id``."""
146249
if not isinstance(tenant_id, str) or not tenant_id:

tests/test_tenant_mgt.py

Lines changed: 172 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414

1515
"""Test cases for the firebase_admin.tenant_mgt module."""
1616

17+
import json
18+
1719
import pytest
1820

1921
import firebase_admin
@@ -36,6 +38,7 @@
3638
}"""
3739

3840
INVALID_TENANT_IDS = [None, '', 0, 1, True, False, list(), tuple(), dict()]
41+
INVALID_BOOLEANS = ['', 1, 0, list(), tuple(), dict()]
3942

4043
TENANT_MGT_URL_PREFIX = 'https://identitytoolkit.googleapis.com/v2beta1/projects/mock-project-id'
4144

@@ -98,11 +101,8 @@ def test_invalid_tenant_id(self, tenant_id):
98101
def test_get_tenant(self, tenant_mgt_app):
99102
_, recorder = _instrument_tenant_mgt(tenant_mgt_app, 200, GET_TENANT_RESPONSE)
100103
tenant = tenant_mgt.get_tenant('tenant-id', app=tenant_mgt_app)
101-
assert tenant.tenant_id == 'tenant-id'
102-
assert tenant.display_name == 'Test Tenant'
103-
assert tenant.allow_password_sign_up is True
104-
assert tenant.enable_email_link_sign_in is True
105104

105+
_assert_tenant(tenant)
106106
assert len(recorder) == 1
107107
req = recorder[0]
108108
assert req.method == 'GET'
@@ -120,6 +120,167 @@ def test_tenant_not_found(self, tenant_mgt_app):
120120
assert excinfo.value.cause is not None
121121

122122

123+
class TestCreateTenant:
124+
125+
@pytest.mark.parametrize('display_name', [True, False, 1, 0, list(), tuple(), dict()])
126+
def test_invalid_display_name(self, display_name, tenant_mgt_app):
127+
with pytest.raises(ValueError) as excinfo:
128+
tenant_mgt.create_tenant(display_name=display_name, app=tenant_mgt_app)
129+
assert str(excinfo.value).startswith('Invalid type for displayName')
130+
131+
@pytest.mark.parametrize('allow', INVALID_BOOLEANS)
132+
def test_invalid_allow_password_sign_up(self, allow, tenant_mgt_app):
133+
with pytest.raises(ValueError) as excinfo:
134+
tenant_mgt.create_tenant(allow_password_sign_up=allow, app=tenant_mgt_app)
135+
assert str(excinfo.value).startswith('Invalid type for allowPasswordSignup')
136+
137+
@pytest.mark.parametrize('enable', INVALID_BOOLEANS)
138+
def test_invalid_enable_email_link_sign_in(self, enable, tenant_mgt_app):
139+
with pytest.raises(ValueError) as excinfo:
140+
tenant_mgt.create_tenant(enable_email_link_sign_in=enable, app=tenant_mgt_app)
141+
assert str(excinfo.value).startswith('Invalid type for enableEmailLinkSignin')
142+
143+
def test_create_tenant(self, tenant_mgt_app):
144+
_, recorder = _instrument_tenant_mgt(tenant_mgt_app, 200, GET_TENANT_RESPONSE)
145+
tenant = tenant_mgt.create_tenant(
146+
display_name='My Tenant', allow_password_sign_up=True, enable_email_link_sign_in=True,
147+
app=tenant_mgt_app)
148+
149+
_assert_tenant(tenant)
150+
self._assert_request(recorder, {
151+
'displayName': 'My Tenant',
152+
'allowPasswordSignup': True,
153+
'enableEmailLinkSignin': True,
154+
})
155+
156+
def test_create_tenant_false_values(self, tenant_mgt_app):
157+
_, recorder = _instrument_tenant_mgt(tenant_mgt_app, 200, GET_TENANT_RESPONSE)
158+
tenant = tenant_mgt.create_tenant(
159+
display_name='', allow_password_sign_up=False, enable_email_link_sign_in=False,
160+
app=tenant_mgt_app)
161+
162+
_assert_tenant(tenant)
163+
self._assert_request(recorder, {
164+
'displayName': '',
165+
'allowPasswordSignup': False,
166+
'enableEmailLinkSignin': False,
167+
})
168+
169+
def test_create_tenant_minimal(self, tenant_mgt_app):
170+
_, recorder = _instrument_tenant_mgt(tenant_mgt_app, 200, GET_TENANT_RESPONSE)
171+
tenant = tenant_mgt.create_tenant(app=tenant_mgt_app)
172+
173+
_assert_tenant(tenant)
174+
self._assert_request(recorder, {})
175+
176+
def test_error(self, tenant_mgt_app):
177+
_instrument_tenant_mgt(tenant_mgt_app, 500, '{}')
178+
with pytest.raises(exceptions.InternalError) as excinfo:
179+
tenant_mgt.create_tenant(app=tenant_mgt_app)
180+
181+
error_msg = 'Unexpected error response: {}'
182+
assert excinfo.value.code == exceptions.INTERNAL
183+
assert str(excinfo.value) == error_msg
184+
assert excinfo.value.http_response is not None
185+
assert excinfo.value.cause is not None
186+
187+
def _assert_request(self, recorder, body):
188+
assert len(recorder) == 1
189+
req = recorder[0]
190+
assert req.method == 'POST'
191+
assert req.url == '{0}/tenants'.format(TENANT_MGT_URL_PREFIX)
192+
got = json.loads(req.body.decode())
193+
assert got == body
194+
195+
196+
class TestUpdateTenant:
197+
198+
@pytest.mark.parametrize('tenant_id', INVALID_TENANT_IDS)
199+
def test_invalid_tenant_id(self, tenant_id, tenant_mgt_app):
200+
with pytest.raises(ValueError) as excinfo:
201+
tenant_mgt.update_tenant(tenant_id, display_name='My Tenant', app=tenant_mgt_app)
202+
assert str(excinfo.value).startswith('Tenant ID must be a non-empty string')
203+
204+
@pytest.mark.parametrize('display_name', [True, False, 1, 0, list(), tuple(), dict()])
205+
def test_invalid_display_name(self, display_name, tenant_mgt_app):
206+
with pytest.raises(ValueError) as excinfo:
207+
tenant_mgt.update_tenant('tenant-id', display_name=display_name, app=tenant_mgt_app)
208+
assert str(excinfo.value).startswith('Invalid type for displayName')
209+
210+
@pytest.mark.parametrize('allow', INVALID_BOOLEANS)
211+
def test_invalid_allow_password_sign_up(self, allow, tenant_mgt_app):
212+
with pytest.raises(ValueError) as excinfo:
213+
tenant_mgt.update_tenant('tenant-id', allow_password_sign_up=allow, app=tenant_mgt_app)
214+
assert str(excinfo.value).startswith('Invalid type for allowPasswordSignup')
215+
216+
@pytest.mark.parametrize('enable', INVALID_BOOLEANS)
217+
def test_invalid_enable_email_link_sign_in(self, enable, tenant_mgt_app):
218+
with pytest.raises(ValueError) as excinfo:
219+
tenant_mgt.update_tenant(
220+
'tenant-id', enable_email_link_sign_in=enable, app=tenant_mgt_app)
221+
assert str(excinfo.value).startswith('Invalid type for enableEmailLinkSignin')
222+
223+
def test_update_tenant(self, tenant_mgt_app):
224+
_, recorder = _instrument_tenant_mgt(tenant_mgt_app, 200, GET_TENANT_RESPONSE)
225+
tenant = tenant_mgt.update_tenant(
226+
'tenant-id', display_name='My Tenant', allow_password_sign_up=True,
227+
enable_email_link_sign_in=True, app=tenant_mgt_app)
228+
229+
_assert_tenant(tenant)
230+
body = {
231+
'displayName': 'My Tenant',
232+
'allowPasswordSignup': True,
233+
'enableEmailLinkSignin': True,
234+
}
235+
mask = ['allowPasswordSignup', 'displayName', 'enableEmailLinkSignin']
236+
self._assert_request(recorder, body, mask)
237+
238+
def test_update_tenant_false_values(self, tenant_mgt_app):
239+
_, recorder = _instrument_tenant_mgt(tenant_mgt_app, 200, GET_TENANT_RESPONSE)
240+
tenant = tenant_mgt.update_tenant(
241+
'tenant-id', display_name='', allow_password_sign_up=False,
242+
enable_email_link_sign_in=False, app=tenant_mgt_app)
243+
244+
_assert_tenant(tenant)
245+
body = {
246+
'displayName': '',
247+
'allowPasswordSignup': False,
248+
'enableEmailLinkSignin': False,
249+
}
250+
mask = ['allowPasswordSignup', 'displayName', 'enableEmailLinkSignin']
251+
self._assert_request(recorder, body, mask)
252+
253+
def test_update_tenant_minimal(self, tenant_mgt_app):
254+
_, recorder = _instrument_tenant_mgt(tenant_mgt_app, 200, GET_TENANT_RESPONSE)
255+
tenant = tenant_mgt.update_tenant(
256+
'tenant-id', display_name='My Tenant', app=tenant_mgt_app)
257+
258+
_assert_tenant(tenant)
259+
body = {'displayName': 'My Tenant'}
260+
mask = ['displayName']
261+
self._assert_request(recorder, body, mask)
262+
263+
def test_tenant_not_found_error(self, tenant_mgt_app):
264+
_instrument_tenant_mgt(tenant_mgt_app, 500, TENANT_NOT_FOUND_RESPONSE)
265+
with pytest.raises(tenant_mgt.TenantNotFoundError) as excinfo:
266+
tenant_mgt.update_tenant('tenant', display_name='My Tenant', app=tenant_mgt_app)
267+
268+
error_msg = 'No tenant found for the given identifier (TENANT_NOT_FOUND).'
269+
assert excinfo.value.code == exceptions.NOT_FOUND
270+
assert str(excinfo.value) == error_msg
271+
assert excinfo.value.http_response is not None
272+
assert excinfo.value.cause is not None
273+
274+
def _assert_request(self, recorder, body, mask):
275+
assert len(recorder) == 1
276+
req = recorder[0]
277+
assert req.method == 'PATCH'
278+
assert req.url == '{0}/tenants/tenant-id?updateMask={1}'.format(
279+
TENANT_MGT_URL_PREFIX, ','.join(mask))
280+
got = json.loads(req.body.decode())
281+
assert got == body
282+
283+
123284
class TestDeleteTenant:
124285

125286
@pytest.mark.parametrize('tenant_id', INVALID_TENANT_IDS)
@@ -146,3 +307,10 @@ def test_tenant_not_found(self, tenant_mgt_app):
146307
assert str(excinfo.value) == error_msg
147308
assert excinfo.value.http_response is not None
148309
assert excinfo.value.cause is not None
310+
311+
312+
def _assert_tenant(tenant):
313+
assert tenant.tenant_id == 'tenant-id'
314+
assert tenant.display_name == 'Test Tenant'
315+
assert tenant.allow_password_sign_up is True
316+
assert tenant.enable_email_link_sign_in is True

0 commit comments

Comments
 (0)