Skip to content

feat(auth): APIs for retrieving and deleting tenants #422

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Mar 20, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions firebase_admin/_auth_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -274,19 +283,20 @@ 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,
}


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)
Expand Down
153 changes: 153 additions & 0 deletions firebase_admin/tenant_mgt.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
# 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',

'delete_tenant',
'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 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)


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):
"""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))

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)

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))

try:
self.client.request('delete', '/tenants/{0}'.format(tenant_id))
except requests.exceptions.RequestException as error:
raise _auth_utils.handle_auth_backend_error(error)
148 changes: 148 additions & 0 deletions tests/test_tenant_mgt.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
# 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 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"
}
}"""

INVALID_TENANT_IDS = [None, '', 0, 1, True, False, list(), tuple(), dict()]

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', INVALID_TENANT_IDS)
def test_invalid_tenant_id(self, tenant_id):
with pytest.raises(ValueError):
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)
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


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