From 35815095d99cf9b41b4f8984e1315de341dba052 Mon Sep 17 00:00:00 2001 From: hiranya911 Date: Fri, 13 Mar 2020 15:54:12 -0700 Subject: [PATCH 1/5] feat(auth): Added Tenant class and get_tenant() API --- firebase_admin/_auth_utils.py | 10 +++ firebase_admin/tenant_mgt.py | 124 ++++++++++++++++++++++++++++++++++ tests/test_tenant_mgt.py | 119 ++++++++++++++++++++++++++++++++ 3 files changed, 253 insertions(+) create mode 100644 firebase_admin/tenant_mgt.py create mode 100644 tests/test_tenant_mgt.py diff --git a/firebase_admin/_auth_utils.py b/firebase_admin/_auth_utils.py index 2f7383c0b..c0ffbfada 100644 --- a/firebase_admin/_auth_utils.py +++ b/firebase_admin/_auth_utils.py @@ -266,6 +266,15 @@ def __init__(self, message, cause=None, http_response=None): exceptions.NotFoundError.__init__(self, message, cause, http_response) +class TenantNotFoundError(exceptions.NotFoundError): + """No tenant found for the specified identifier.""" + + default_message = 'No tenant found for the given identifier' + + def __init__(self, message, cause=None, http_response=None): + exceptions.NotFoundError.__init__(self, message, cause, http_response) + + _CODE_TO_EXC_TYPE = { 'DUPLICATE_EMAIL': EmailAlreadyExistsError, 'DUPLICATE_LOCAL_ID': UidAlreadyExistsError, @@ -274,6 +283,7 @@ def __init__(self, message, cause=None, http_response=None): 'INVALID_DYNAMIC_LINK_DOMAIN': InvalidDynamicLinkDomainError, 'INVALID_ID_TOKEN': InvalidIdTokenError, 'PHONE_NUMBER_EXISTS': PhoneNumberAlreadyExistsError, + 'TENANT_NOT_FOUND': TenantNotFoundError, 'USER_NOT_FOUND': UserNotFoundError, } diff --git a/firebase_admin/tenant_mgt.py b/firebase_admin/tenant_mgt.py new file mode 100644 index 000000000..b95372485 --- /dev/null +++ b/firebase_admin/tenant_mgt.py @@ -0,0 +1,124 @@ +# Copyright 2020 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Firebase tenant management module. + +This module contains functions for creating and configuring authentication tenants within a +Google Cloud Identity Platform (GCIP) instance. +""" + +import requests + +import firebase_admin +from firebase_admin import _auth_utils +from firebase_admin import _http_client +from firebase_admin import _utils + + +_TENANT_MGT_ATTRIBUTE = '_tenant_mgt' + + +__all__ = [ + 'Tenant', + 'TenantNotFoundError', + + 'get_tenant', +] + +TenantNotFoundError = _auth_utils.TenantNotFoundError + + +def get_tenant(tenant_id, app=None): + """Gets the tenant corresponding to the given ``tenant_id``. + + Args: + tenant_id: A tenant ID string. + app: An App instance (optional). + + Returns: + Tenant: A Tenant object. + + Raises: + ValueError: If the tenant ID is None, empty or not a string. + TenantNotFoundError: If no tenant exists by the given ID. + FirebaseError: If an error occurs while retrieving the tenant. + """ + tenant_mgt_service = _get_tenant_mgt_service(app) + return tenant_mgt_service.get_tenant(tenant_id) + + +def _get_tenant_mgt_service(app): + return _utils.get_app_service(app, _TENANT_MGT_ATTRIBUTE, _TenantManagementService) + + +class Tenant: + """Represents a tenant in a multi-tenant application. + + Multi-tenancy support requires Google Cloud Identity Platform (GCIP). To learn more about + GCIP including pricing and features, see https://cloud.google.com/identity-platform. + + Before multi-tenancy can be used in a Google Cloud Identity Platform project, tenants must be + enabled in that project via the Cloud Console UI. A Tenant instance provides information + such as the display name, tenant identifier and email authentication configuration. + """ + + def __init__(self, data): + if not isinstance(data, dict): + raise ValueError('Invalid data argument in Tenant constructor: {0}'.format(data)) + if not 'name' in data: + raise ValueError('Tenant response missing required keys.') + + self._data = data + + @property + def tenant_id(self): + name = self._data['name'] + return name.split('/')[-1] + + @property + def display_name(self): + return self._data.get('displayName') + + @property + def allow_password_sign_up(self): + return self._data.get('allowPasswordSignup', False) + + @property + def enable_email_link_sign_in(self): + return self._data.get('enableEmailLinkSignin', False) + + +class _TenantManagementService: + """Firebase tenant management service.""" + + TENANT_MGT_URL = 'https://identitytoolkit.googleapis.com/v2beta1' + + def __init__(self, app): + credential = app.credential.get_credential() + version_header = 'Python/Admin/{0}'.format(firebase_admin.__version__) + base_url = '{0}/projects/{1}'.format(self.TENANT_MGT_URL, app.project_id) + self.client = _http_client.JsonHttpClient( + credential=credential, base_url=base_url, headers={'X-Client-Version': version_header}) + + def get_tenant(self, tenant_id): + if not isinstance(tenant_id, str) or not tenant_id: + raise ValueError( + 'Invalid tenant ID: {0}. Tenant ID must be a non-empty string.'.format(tenant_id)) + + try: + body = self.client.body('get', '/tenants/{0}'.format(tenant_id)) + except requests.exceptions.RequestException as error: + raise _auth_utils.handle_auth_backend_error(error) + else: + return Tenant(body) diff --git a/tests/test_tenant_mgt.py b/tests/test_tenant_mgt.py new file mode 100644 index 000000000..34be83e94 --- /dev/null +++ b/tests/test_tenant_mgt.py @@ -0,0 +1,119 @@ +# Copyright 2020 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Test cases for the firebase_admin.tenant_mgt module.""" + +import pytest + +import firebase_admin +from firebase_admin import exceptions +from firebase_admin import tenant_mgt +from firebase_admin import _auth_utils +from tests import testutils + + +GET_TENANT_RESPONSE = """{ + "name": "projects/mock-project-id/tenants/tenant-id", + "displayName": "Test Tenant", + "allowPasswordSignup": true, + "enableEmailLinkSignin": true +}""" + +TENANT_NOT_FOUND_RESPONSE = """{ + "error": { + "message": "TENANT_NOT_FOUND" + } +}""" + +TENANT_MGT_URL_PREFIX = 'https://identitytoolkit.googleapis.com/v2beta1/projects/mock-project-id' + + +@pytest.fixture(scope='module') +def tenant_mgt_app(): + app = firebase_admin.initialize_app( + testutils.MockCredential(), name='tenantMgt', options={'projectId': 'mock-project-id'}) + yield app + firebase_admin.delete_app(app) + + +def _instrument_tenant_mgt(app, status, payload): + service = tenant_mgt._get_tenant_mgt_service(app) + recorder = [] + service.client.session.mount( + tenant_mgt._TenantManagementService.TENANT_MGT_URL, + testutils.MockAdapter(payload, status, recorder)) + return service, recorder + + +class TestTenant: + + @pytest.mark.parametrize('data', [None, 'foo', 0, 1, True, False, list(), tuple(), dict()]) + def test_invalid_data(self, data): + with pytest.raises(ValueError): + tenant_mgt.Tenant(data) + + def test_tenant(self): + data = { + 'name': 'projects/test-project/tenants/tenant-id', + 'displayName': 'Test Tenant', + 'allowPasswordSignup': True, + 'enableEmailLinkSignin': True, + } + tenant = tenant_mgt.Tenant(data) + assert tenant.tenant_id == 'tenant-id' + assert tenant.display_name == 'Test Tenant' + assert tenant.allow_password_sign_up is True + assert tenant.enable_email_link_sign_in is True + + def test_tenant_optional_params(self): + data = { + 'name': 'projects/test-project/tenants/tenant-id', + } + tenant = tenant_mgt.Tenant(data) + assert tenant.tenant_id == 'tenant-id' + assert tenant.display_name is None + assert tenant.allow_password_sign_up is False + assert tenant.enable_email_link_sign_in is False + + +class TestGetTenant: + + @pytest.mark.parametrize('tenant_id', [None, '', 0, 1, True, False, list(), tuple(), dict()]) + def test_invalid_tenant_id(self, tenant_id): + with pytest.raises(ValueError): + tenant_mgt.get_tenant(tenant_id) + + def test_get_tenant(self, tenant_mgt_app): + _, recorder = _instrument_tenant_mgt(tenant_mgt_app, 200, GET_TENANT_RESPONSE) + tenant = tenant_mgt.get_tenant('tenant-id', app=tenant_mgt_app) + assert tenant.tenant_id == 'tenant-id' + assert tenant.display_name == 'Test Tenant' + assert tenant.allow_password_sign_up is True + assert tenant.enable_email_link_sign_in is True + + assert len(recorder) == 1 + req = recorder[0] + assert req.method == 'GET' + assert req.url == '{0}/tenants/tenant-id'.format(TENANT_MGT_URL_PREFIX) + + def test_tenant_not_found(self, tenant_mgt_app): + _instrument_tenant_mgt(tenant_mgt_app, 500, TENANT_NOT_FOUND_RESPONSE) + with pytest.raises(tenant_mgt.TenantNotFoundError) as excinfo: + tenant_mgt.get_tenant('tenant-id', app=tenant_mgt_app) + + error_msg = 'No tenant found for the given identifier (TENANT_NOT_FOUND).' + assert excinfo.value.code == exceptions.NOT_FOUND + assert str(excinfo.value) == error_msg + assert excinfo.value.http_response is not None + assert excinfo.value.cause is not None From 9d58b1e4da9f3c7c27d7a0edb8083fac330a5421 Mon Sep 17 00:00:00 2001 From: hiranya911 Date: Mon, 16 Mar 2020 15:54:28 -0700 Subject: [PATCH 2/5] Added delete_tenant() API --- firebase_admin/_auth_utils.py | 4 ++-- firebase_admin/tenant_mgt.py | 26 ++++++++++++++++++++++++++ tests/test_tenant_mgt.py | 34 ++++++++++++++++++++++++++++++++-- 3 files changed, 60 insertions(+), 4 deletions(-) diff --git a/firebase_admin/_auth_utils.py b/firebase_admin/_auth_utils.py index c0ffbfada..7048616bb 100644 --- a/firebase_admin/_auth_utils.py +++ b/firebase_admin/_auth_utils.py @@ -291,12 +291,12 @@ def __init__(self, message, cause=None, http_response=None): def handle_auth_backend_error(error): """Converts a requests error received from the Firebase Auth service into a FirebaseError.""" if error.response is None: - raise _utils.handle_requests_error(error) + return _utils.handle_requests_error(error) code, custom_message = _parse_error_body(error.response) if not code: msg = 'Unexpected error response: {0}'.format(error.response.content.decode()) - raise _utils.handle_requests_error(error, message=msg) + return _utils.handle_requests_error(error, message=msg) exc_type = _CODE_TO_EXC_TYPE.get(code) msg = _build_error_message(code, exc_type, custom_message) diff --git a/firebase_admin/tenant_mgt.py b/firebase_admin/tenant_mgt.py index b95372485..efa52ab46 100644 --- a/firebase_admin/tenant_mgt.py +++ b/firebase_admin/tenant_mgt.py @@ -58,6 +58,22 @@ def get_tenant(tenant_id, app=None): return tenant_mgt_service.get_tenant(tenant_id) +def delete_tenant(tenant_id, app=None): + """Deletes the tenant corresponding to the given ``tenant_id``. + + Args: + tenant_id: A tenant ID string. + app: An App instance (optional). + + Raises: + ValueError: If the tenant ID is None, empty or not a string. + TenantNotFoundError: If no tenant exists by the given ID. + FirebaseError: If an error occurs while retrieving the tenant. + """ + tenant_mgt_service = _get_tenant_mgt_service(app) + tenant_mgt_service.delete_tenant(tenant_id) + + def _get_tenant_mgt_service(app): return _utils.get_app_service(app, _TENANT_MGT_ATTRIBUTE, _TenantManagementService) @@ -122,3 +138,13 @@ def get_tenant(self, tenant_id): raise _auth_utils.handle_auth_backend_error(error) else: return Tenant(body) + + def delete_tenant(self, tenant_id): + if not isinstance(tenant_id, str) or not tenant_id: + raise ValueError( + 'Invalid tenant ID: {0}. Tenant ID must be a non-empty string.'.format(tenant_id)) + + try: + self.client.request('delete', '/tenants/{0}'.format(tenant_id)) + except requests.exceptions.RequestException as error: + raise _auth_utils.handle_auth_backend_error(error) diff --git a/tests/test_tenant_mgt.py b/tests/test_tenant_mgt.py index 34be83e94..2d4481c4a 100644 --- a/tests/test_tenant_mgt.py +++ b/tests/test_tenant_mgt.py @@ -36,6 +36,8 @@ } }""" +INVALID_TENANT_IDS = [None, '', 0, 1, True, False, list(), tuple(), dict()] + TENANT_MGT_URL_PREFIX = 'https://identitytoolkit.googleapis.com/v2beta1/projects/mock-project-id' @@ -89,10 +91,10 @@ def test_tenant_optional_params(self): class TestGetTenant: - @pytest.mark.parametrize('tenant_id', [None, '', 0, 1, True, False, list(), tuple(), dict()]) + @pytest.mark.parametrize('tenant_id', INVALID_TENANT_IDS) def test_invalid_tenant_id(self, tenant_id): with pytest.raises(ValueError): - tenant_mgt.get_tenant(tenant_id) + tenant_mgt.delete_tenant(tenant_id) def test_get_tenant(self, tenant_mgt_app): _, recorder = _instrument_tenant_mgt(tenant_mgt_app, 200, GET_TENANT_RESPONSE) @@ -117,3 +119,31 @@ def test_tenant_not_found(self, tenant_mgt_app): assert str(excinfo.value) == error_msg assert excinfo.value.http_response is not None assert excinfo.value.cause is not None + + +class TestDeleteTenant: + + @pytest.mark.parametrize('tenant_id', INVALID_TENANT_IDS) + def test_invalid_tenant_id(self, tenant_id): + with pytest.raises(ValueError): + tenant_mgt.delete_tenant(tenant_id) + + def test_delete_tenant(self, tenant_mgt_app): + _, recorder = _instrument_tenant_mgt(tenant_mgt_app, 200, '{}') + tenant_mgt.delete_tenant('tenant-id', app=tenant_mgt_app) + + assert len(recorder) == 1 + req = recorder[0] + assert req.method == 'DELETE' + assert req.url == '{0}/tenants/tenant-id'.format(TENANT_MGT_URL_PREFIX) + + def test_tenant_not_found(self, tenant_mgt_app): + _instrument_tenant_mgt(tenant_mgt_app, 500, TENANT_NOT_FOUND_RESPONSE) + with pytest.raises(tenant_mgt.TenantNotFoundError) as excinfo: + tenant_mgt.delete_tenant('tenant-id', app=tenant_mgt_app) + + error_msg = 'No tenant found for the given identifier (TENANT_NOT_FOUND).' + assert excinfo.value.code == exceptions.NOT_FOUND + assert str(excinfo.value) == error_msg + assert excinfo.value.http_response is not None + assert excinfo.value.cause is not None From e7723f1c37dc5290ee8704baed1a583645740d4b Mon Sep 17 00:00:00 2001 From: hiranya911 Date: Mon, 16 Mar 2020 16:00:22 -0700 Subject: [PATCH 3/5] Added delete_tenant to _all_ list --- firebase_admin/tenant_mgt.py | 1 + 1 file changed, 1 insertion(+) diff --git a/firebase_admin/tenant_mgt.py b/firebase_admin/tenant_mgt.py index efa52ab46..f6161b903 100644 --- a/firebase_admin/tenant_mgt.py +++ b/firebase_admin/tenant_mgt.py @@ -33,6 +33,7 @@ 'Tenant', 'TenantNotFoundError', + 'delete_tenant', 'get_tenant', ] From e991fd5147f66c62efa01992e3eebed06f49e1db Mon Sep 17 00:00:00 2001 From: hiranya911 Date: Tue, 17 Mar 2020 14:17:18 -0700 Subject: [PATCH 4/5] Fixing a lint error --- firebase_admin/tenant_mgt.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/firebase_admin/tenant_mgt.py b/firebase_admin/tenant_mgt.py index f6161b903..43d22ea50 100644 --- a/firebase_admin/tenant_mgt.py +++ b/firebase_admin/tenant_mgt.py @@ -129,6 +129,7 @@ def __init__(self, app): credential=credential, base_url=base_url, headers={'X-Client-Version': version_header}) def get_tenant(self, tenant_id): + """Gets the tenant corresponding to the given ``tenant_id``.""" if not isinstance(tenant_id, str) or not tenant_id: raise ValueError( 'Invalid tenant ID: {0}. Tenant ID must be a non-empty string.'.format(tenant_id)) @@ -141,6 +142,7 @@ def get_tenant(self, tenant_id): return Tenant(body) def delete_tenant(self, tenant_id): + """Deletes the tenant corresponding to the given ``tenant_id``.""" if not isinstance(tenant_id, str) or not tenant_id: raise ValueError( 'Invalid tenant ID: {0}. Tenant ID must be a non-empty string.'.format(tenant_id)) From 1dddc918e146891e337f4a32be3d7ba74c620b13 Mon Sep 17 00:00:00 2001 From: hiranya911 Date: Tue, 17 Mar 2020 14:20:47 -0700 Subject: [PATCH 5/5] Fixing a lint error --- tests/test_tenant_mgt.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_tenant_mgt.py b/tests/test_tenant_mgt.py index 2d4481c4a..7d71ca59d 100644 --- a/tests/test_tenant_mgt.py +++ b/tests/test_tenant_mgt.py @@ -19,7 +19,6 @@ import firebase_admin from firebase_admin import exceptions from firebase_admin import tenant_mgt -from firebase_admin import _auth_utils from tests import testutils