8000 feat(auth): APIs for retrieving and deleting tenants (#422) · ClarkDing/firebase-admin-python@11b5a6b · GitHub
[go: up one dir, main page]

Skip to content

Commit 11b5a6b

Browse files
authored
feat(auth): APIs for retrieving and deleting tenants (firebase#422)
* feat(auth): Added Tenant class and get_tenant() API * Added delete_tenant() API * Added delete_tenant to _all_ list * Fixing a lint error * Fixing a lint error
1 parent 9805758 commit 11b5a6b

File tree

3 files changed

+313
-2
lines changed

3 files changed

+313
-2
lines changed

firebase_admin/_auth_utils.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,15 @@ def __init__(self, message, cause=None, http_response=None):
266266
exceptions.NotFoundError.__init__(self, message, cause, http_response)
267267

268268

269+
class TenantNotFoundError(exceptions.NotFoundError):
270+
"""No tenant found for the specified identifier."""
271+
272+
default_message = 'No tenant found for the given identifier'
273+
274+
def __init__(self, message, cause=None, http_response=None):
275+
exceptions.NotFoundError.__init__(self, message, cause, http_response)
276+
277+
269278
_CODE_TO_EXC_TYPE = {
270279
'DUPLICATE_EMAIL': EmailAlreadyExistsError,
271280
'DUPLICATE_LOCAL_ID': UidAlreadyExistsError,
@@ -274,19 +283,20 @@ def __init__(self, message, cause=None, http_response=None):
274283
'INVALID_DYNAMIC_LINK_DOMAIN': InvalidDynamicLinkDomainError,
275284
'INVALID_ID_TOKEN': InvalidIdTokenError,
276285
'PHONE_NUMBER_EXISTS': PhoneNumberAlreadyExistsError,
286+
'TENANT_NOT_FOUND': TenantNotFoundError,
277287
'USER_NOT_FOUND': UserNotFoundError,
278288
}
279289

280290

281291
def handle_auth_backend_error(error):
282292
"""Converts a requests error received from the Firebase Auth service into a FirebaseError."""
283293
if error.response is None:
284-
raise _utils.handle_requests_error(error)
294+
return _utils.handle_requests_error(error)
285295

286296
code, custom_message = _parse_error_body(error.response)
287297
if not code:
288298
msg = 'Unexpected error response: {0}'.format(error.response.content.decode())
289-
raise _utils.handle_requests_error(error, message=msg)
299+
return _utils.handle_requests_error(error, message=msg)
290300

291301
exc_type = _CODE_TO_EXC_TYPE.get(code)
292302
msg = _build_error_message(code, exc_type, custom_message)

firebase_admin/tenant_mgt.py

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
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+
"""Firebase tenant management module.
16+
17+
This module contains functions for creating and configuring authentication tenants within a
18+
Google Cloud Identity Platform (GCIP) instance.
19+
"""
20+
21+
import requests
22+
23+
import firebase_admin
24+
from firebase_admin import _auth_utils
25+
from firebase_admin import _http_client
26+
from firebase_admin import _utils
27+
28+
29+
_TENANT_MGT_ATTRIBUTE = '_tenant_mgt'
30+
31+
32+
__all__ = [
33+
'Tenant',
34+
'TenantNotFoundError',
35+
36+
'delete_tenant',
37+
'get_tenant',
38+
]
39+
40+
TenantNotFoundError = _auth_utils.TenantNotFoundError
41+
42+
43+
def get_tenant(tenant_id, app=None):
44+
"""Gets the tenant corresponding to the given ``tenant_id``.
45+
46+
Args:
47+
tenant_id: A tenant ID string.
48+
app: An App instance (optional).
49+
50+
Returns:
51+
Tenant: A Tenant object.
52+
53+
Raises:
54+
ValueError: If the tenant ID is None, empty or not a string.
55+
TenantNotFoundError: If no tenant exists by the given ID.
56+
FirebaseError: If an error occurs while retrieving the tenant.
57+
"""
58+
tenant_mgt_service = _get_tenant_mgt_service(app)
59+
return tenant_mgt_service.get_tenant(tenant_id)
60+
61+
62+
def delete_tenant(tenant_id, app=None):
63+
"""Deletes the tenant corresponding to the given ``tenant_id``.
64+
65+
Args:
66+
tenant_id: A tenant ID string.
67+
app: An App instance (optional).
68+
69+
Raises:
70+
ValueError: If the tenant ID is None, empty or not a string.
71+
TenantNotFoundError: If no tenant exists by the given ID.
72+
FirebaseError: If an error occurs while retrieving the tenant.
73+
"""
74+
tenant_mgt_service = _get_tenant_mgt_service(app)
75+
tenant_mgt_service.delete_tenant(tenant_id)
76+
77+
78+
def _get_tenant_mgt_service(app):
79+
return _utils.get_app_service(app, _TENANT_MGT_ATTRIBUTE, _TenantManagementService)
80+
81+
82+
class Tenant:
83+
"""Represents a tenant in a multi-tenant application.
84+
85+
Multi-tenancy support requires Google Cloud Identity Platform (GCIP). To learn more about
86+
GCIP including pricing and features, see https://cloud.google.com/identity-platform.
87+
88+
Before multi-tenancy can be used in a Google Cloud Identity Platform project, tenants must be
89+
enabled in that project via the Cloud Console UI. A Tenant instance provides information
90+
such as the display name, tenant identifier and email authentication configuration.
91+
"""
92+
93+
def __init__(self, data):
94+
if not isinstance(data, dict):
95+
raise ValueError('Invalid data argument in Tenant constructor: {0}'.format(data))
96+
if not 'name' in data:
97+
raise ValueError('Tenant response missing required keys.')
98+
99+
self._data = data
100+
101+
@property
102+
def tenant_id(self):
103+
name = self._data['name']
104+
return name.split('/')[-1]
105+
106+
@property
107+
def display_name(self):
108+
return self._data.get('displayName')
109+
110+
@property
111+
def allow_password_sign_up(self):
112+
return self._data.get('allowPasswordSignup', False)
113+
114+
@property
115+
def enable_email_link_sign_in(self):
116+
return self._data.get('enableEmailLinkSignin', False)
117+
118+
119+
class _TenantManagementService:
120+
"""Firebase tenant management service."""
121+
122+
TENANT_MGT_URL = 'https://identitytoolkit.googleapis.com/v2beta1'
123+
124+
def __init__(self, app):
125+
credential = app.credential.get_credential()
126+
version_header = 'Python/Admin/{0}'.format(firebase_admin.__version__)
127+
base_url = '{0}/projects/{1}'.format(self.TENANT_MGT_URL, app.project_id)
128+
self.client = _http_client.JsonHttpClient(
129+
credential=credential, base_url=base_url, headers={'X-Client-Version': version_header})
130+
131+
def get_tenant(self, tenant_id):
132+
"""Gets the tenant corresponding to the given ``tenant_id``."""
133+
if not isinstance(tenant_id, str) or not tenant_id:
134+
raise ValueError(
135+
'Invalid tenant ID: {0}. Tenant ID must be a non-empty string.'.format(tenant_id))
136+
137+
try:
138+
body = self.client.body('get', '/tenants/{0}'.format(tenant_id))
139+
except requests.exceptions.RequestException as error:
140+
raise _auth_utils.handle_auth_backend_error(error)
141+
else:
142+
return Tenant(body)
143+
144+
def delete_tenant(self, tenant_id):
145+
"""Deletes the tenant corresponding to the given ``tenant_id``."""
146+
if not isinstance(tenant_id, str) or not tenant_id:
147+
raise ValueError(
148+
'Invalid tenant ID: {0}. Tenant ID must be a non-empty string.'.format(tenant_id))
149+
150+
try:
151+
self.client.request('delete', '/tenants/{0}'.format(tenant_id))
152+
except requests.exceptions.RequestException as error:
153+
raise _auth_utils.handle_auth_backend_error(error)

tests/test_tenant_mgt.py

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
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+
"""Test cases for the firebase_admin.tenant_mgt module."""
16+
17+
import pytest
18+
19+
import firebase_admin
20+
from firebase_admin import exceptions
21+
from firebase_admin import tenant_mgt
22+
from tests import testutils
23+
24+
25+
GET_TENANT_RESPONSE = """{
26+
"name": "projects/mock-project-id/tenants/tenant-id",
27+
"displayName": "Test Tenant",
28+
"allowPasswordSignup": true,
29+
"enableEmailLinkSignin": true
30+
}"""
31+
32+
TENANT_NOT_FOUND_RESPONSE = """{
33+
"error": {
34+
"message": "TENANT_NOT_FOUND"
35+
}
36+
}"""
37+
38+
INVALID_TENANT_IDS = [None, '', 0, 1, True, False, list(), tuple(), dict()]
39+
40+
TENANT_MGT_URL_PREFIX = 'https://identitytoolkit.googleapis.com/v2beta1/projects/mock-project-id'
41+
42+
43+
@pytest.fixture(scope='module')
44+
def tenant_mgt_app():
45+
app = firebase_admin.initialize_app(
46+
testutils.MockCredential(), name='tenantMgt', options={'projectId': 'mock-project-id'})
47+
yield app
48+
firebase_admin.delete_app(app)
49+
50+
51+
def _instrument_tenant_mgt(app, status, payload):
52+
service = tenant_mgt._get_tenant_mgt_service(app)
53+
recorder = []
54+
service.client.session.mount(
55+
tenant_mgt._TenantManagementService.TENANT_MGT_URL,
56+
testutils.MockAdapter(payload, status, recorder))
57+
return service, recorder
58+
59+
60+
class TestTenant:
61+
62+
@pytest.mark.parametrize('data', [None, 'foo', 0, 1, True, False, list(), tuple(), dict()])
63+
def test_invalid_data(self, data):
64+
with pytest.raises(ValueError):
65+
tenant_mgt.Tenant(data)
66+
67+
def test_tenant(self):
68+
data = {
69+
'name': 'projects/test-project/tenants/tenant-id',
70+
'displayName': 'Test Tenant',
71+
'allowPasswordSignup': True,
72+
'enableEmailLinkSignin': True,
73+
}
74+
tenant = tenant_mgt.Tenant(data)
75+
assert tenant.tenant_id == 'tenant-id'
76+
assert tenant.display_name == 'Test Tenant'
77+
assert tenant.allow_password_sign_up is True
78+
assert tenant.enable_email_link_sign_in is True
79+
80+
def test_tenant_optional_params(self):
81+
data = {
82+
'name': 'projects/test-project/tenants/tenant-id',
83+
}
84+
tenant = tenant_mgt.Tenant(data)
85+
assert tenant.tenant_id == 'tenant-id'
86+
assert tenant.display_name is None
87+
assert tenant.allow_password_sign_up is False
88+
assert tenant.enable_email_link_sign_in is False
89+
90+
91+
class TestGetTenant:
92+
93+
@pytest.mark.parametrize('tenant_id', INVALID_TENANT_IDS)
94+
def test_invalid_tenant_id(self, tenant_id):
95+
with pytest.raises(ValueError):
96+
tenant_mgt.delete_tenant(tenant_id)
97+
98+
def test_get_tenant(self, tenant_mgt_app):
99+
_, recorder = _instrument_tenant_mgt(tenant_mgt_app, 200, GET_TENANT_RESPONSE)
100+
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
105+
106+
assert len(recorder) == 1
107+
req = recorder[0]
108+
assert req.method == 'GET'
109+
assert req.url == '{0}/tenants/tenant-id'.format(TENANT_MGT_URL_PREFIX)
110+
111+
def test_tenant_not_found(self, tenant_mgt_app):
112+
_instrument_tenant_mgt(tenant_mgt_app, 500, TENANT_NOT_FOUND_RESPONSE)
113+
with pytest.raises(tenant_mgt.TenantNotFoundError) as excinfo:
114+
tenant_mgt.get_tenant('tenant-id', app=tenant_mgt_app)
115+
116+
error_msg = 'No tenant found for the given identifier (TENANT_NOT_FOUND).'
117+
assert excinfo.value.code == exceptions.NOT_FOUND
118+
assert str(excinfo.value) == error_msg
119+
assert excinfo.value.http_response is not None
120+
assert excinfo.value.cause is not None
121+
122+
123+
class TestDeleteTenant:
124+
125+
@pytest.mark.parametrize('tenant_id', INVALID_TENANT_IDS)
126+
def test_invalid_tenant_id(self, tenant_id):
127+
with pytest.raises(ValueError):
128+
tenant_mgt.delete_tenant(tenant_id)
129+
130+
def test_delete_tenant(self, tenant_mgt_app):
131+
_, recorder = _instrument_tenant_mgt(tenant_mgt_app, 200, '{}')
132+
tenant_mgt.delete_tenant('tenant-id', app=tenant_mgt_app)
133+
134+
assert len(recorder) == 1
135+
req = recorder[0]
136+
assert req.method == 'DELETE'
137+
assert req.url == '{0}/tenants/tenant-id'.format(TENANT_MGT_URL_PREFIX)
138+
139+
def test_tenant_not_found(self, tenant_mgt_app):
140+
_instrument_tenant_mgt(tenant_mgt_app, 500, TENANT_NOT_FOUND_RESPONSE)
141+
with pytest.raises(tenant_mgt.TenantNotFoundError) as excinfo:
142+
tenant_mgt.delete_tenant('tenant-id', app=tenant_mgt_app)
143+
144+
error_msg = 'No tenant found for the given identifier (TENANT_NOT_FOUND).'
145+
assert excinfo.value.code == exceptions.NOT_FOUND
146+
assert str(excinfo.value) == error_msg
147+
assert excinfo.value.http_response is not None
148+
assert excinfo.value.cause is not None

0 commit comments

Comments
 (0)
0