446 lines
17 KiB
Python
446 lines
17 KiB
Python
|
# 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 re
|
||
|
import threading
|
||
|
|
||
|
import requests
|
||
|
|
||
|
import firebase_admin
|
||
|
from firebase_admin import auth
|
||
|
from firebase_admin import _auth_utils
|
||
|
from firebase_admin import _http_client
|
||
|
from firebase_admin import _utils
|
||
|
|
||
|
|
||
|
_TENANT_MGT_ATTRIBUTE = '_tenant_mgt'
|
||
|
_MAX_LIST_TENANTS_RESULTS = 100
|
||
|
_DISPLAY_NAME_PATTERN = re.compile('^[a-zA-Z][a-zA-Z0-9-]{3,19}$')
|
||
|
|
||
|
|
||
|
__all__ = [
|
||
|
'ListTenantsPage',
|
||
|
'Tenant',
|
||
|
'TenantIdMismatchError',
|
||
|
'TenantNotFoundError',
|
||
|
|
||
|
'auth_for_tenant',
|
||
|
'create_tenant',
|
||
|
'delete_tenant',
|
||
|
'get_tenant',
|
||
|
'list_tenants',
|
||
|
'update_tenant',
|
||
|
]
|
||
|
|
||
|
|
||
|
TenantIdMismatchError = _auth_utils.TenantIdMismatchError
|
||
|
TenantNotFoundError = _auth_utils.TenantNotFoundError
|
||
|
|
||
|
|
||
|
def auth_for_tenant(tenant_id, app=None):
|
||
|
"""Gets an Auth Client instance scoped to the given tenant ID.
|
||
|
|
||
|
Args:
|
||
|
tenant_id: A tenant ID string.
|
||
|
app: An App instance (optional).
|
||
|
|
||
|
Returns:
|
||
|
auth.Client: An ``auth.Client`` object.
|
||
|
|
||
|
Raises:
|
||
|
ValueError: If the tenant ID is None, empty or not a string.
|
||
|
"""
|
||
|
tenant_mgt_service = _get_tenant_mgt_service(app)
|
||
|
return tenant_mgt_service.auth_for_tenant(tenant_id)
|
||
|
|
||
|
|
||
|
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 create_tenant(
|
||
|
display_name, allow_password_sign_up=None, enable_email_link_sign_in=None, app=None):
|
||
|
"""Creates a new tenant from the given options.
|
||
|
|
||
|
Args:
|
||
|
display_name: Display name string for the new tenant. Must begin with a letter and contain
|
||
|
only letters, digits and hyphens. Length must be between 4 and 20.
|
||
|
allow_password_sign_up: A boolean indicating whether to enable or disable the email sign-in
|
||
|
provider (optional).
|
||
|
enable_email_link_sign_in: A boolean indicating whether to enable or disable email link
|
||
|
sign-in (optional). Disabling this makes the password required for email sign-in.
|
||
|
app: An App instance (optional).
|
||
|
|
||
|
Returns:
|
||
|
Tenant: A tenant object.
|
||
|
|
||
|
Raises:
|
||
|
ValueError: If any of the given arguments are invalid.
|
||
|
FirebaseError: If an error occurs while creating the tenant.
|
||
|
"""
|
||
|
tenant_mgt_service = _get_tenant_mgt_service(app)
|
||
|
return tenant_mgt_service.create_tenant(
|
||
|
display_name=display_name, allow_password_sign_up=allow_password_sign_up,
|
||
|
enable_email_link_sign_in=enable_email_link_sign_in)
|
||
|
|
||
|
|
||
|
def update_tenant(
|
||
|
tenant_id, display_name=None, allow_password_sign_up=None, enable_email_link_sign_in=None,
|
||
|
app=None):
|
||
|
"""Updates an existing tenant with the given options.
|
||
|
|
||
|
Args:
|
||
|
tenant_id: ID of the tenant to update.
|
||
|
display_name: Updated display name string for the tenant (optional).
|
||
|
allow_password_sign_up: A boolean indicating whether to enable or disable the email sign-in
|
||
|
provider.
|
||
|
enable_email_link_sign_in: A boolean indicating whether to enable or disable email link
|
||
|
sign-in. Disabling this makes the password required for email sign-in.
|
||
|
app: An App instance (optional).
|
||
|
|
||
|
Returns:
|
||
|
Tenant: The updated tenant object.
|
||
|
|
||
|
Raises:
|
||
|
ValueError: If any of the given arguments are invalid.
|
||
|
TenantNotFoundError: If no tenant exists by the given ID.
|
||
|
FirebaseError: If an error occurs while creating the tenant.
|
||
|
"""
|
||
|
tenant_mgt_service = _get_tenant_mgt_service(app)
|
||
|
return tenant_mgt_service.update_tenant(
|
||
|
tenant_id, display_name=display_name, allow_password_sign_up=allow_password_sign_up,
|
||
|
enable_email_link_sign_in=enable_email_link_sign_in)
|
||
|
|
||
|
|
||
|
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 list_tenants(page_token=None, max_results=_MAX_LIST_TENANTS_RESULTS, app=None):
|
||
|
"""Retrieves a page of tenants from a Firebase project.
|
||
|
|
||
|
The ``page_token`` argument governs the starting point of the page. The ``max_results``
|
||
|
argument governs the maximum number of tenants that may be included in the returned page.
|
||
|
This function never returns None. If there are no user accounts in the Firebase project, this
|
||
|
returns an empty page.
|
||
|
|
||
|
Args:
|
||
|
page_token: A non-empty page token string, which indicates the starting point of the page
|
||
|
(optional). Defaults to ``None``, which will retrieve the first page of users.
|
||
|
max_results: A positive integer indicating the maximum number of users to include in the
|
||
|
returned page (optional). Defaults to 100, which is also the maximum number allowed.
|
||
|
app: An App instance (optional).
|
||
|
|
||
|
Returns:
|
||
|
ListTenantsPage: A page of tenants.
|
||
|
|
||
|
Raises:
|
||
|
ValueError: If ``max_results`` or ``page_token`` are invalid.
|
||
|
FirebaseError: If an error occurs while retrieving the user accounts.
|
||
|
"""
|
||
|
tenant_mgt_service = _get_tenant_mgt_service(app)
|
||
|
def download(page_token, max_results):
|
||
|
return tenant_mgt_service.list_tenants(page_token, max_results)
|
||
|
return ListTenantsPage(download, page_token, max_results)
|
||
|
|
||
|
|
||
|
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.app = app
|
||
|
self.client = _http_client.JsonHttpClient(
|
||
|
credential=credential, base_url=base_url, headers={'X-Client-Version': version_header})
|
||
|
self.tenant_clients = {}
|
||
|
self.lock = threading.RLock()
|
||
|
|
||
|
def auth_for_tenant(self, tenant_id):
|
||
|
"""Gets an Auth Client instance scoped 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))
|
||
|
|
||
|
with self.lock:
|
||
|
if tenant_id in self.tenant_clients:
|
||
|
return self.tenant_clients[tenant_id]
|
||
|
|
||
|
client = auth.Client(self.app, tenant_id=tenant_id)
|
||
|
self.tenant_clients[tenant_id] = client
|
||
|
return client
|
||
|
|
||
|
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 create_tenant(
|
||
|
self, display_name, allow_password_sign_up=None, enable_email_link_sign_in=None):
|
||
|
"""Creates a new tenant from the given parameters."""
|
||
|
|
||
|
payload = {'displayName': _validate_display_name(display_name)}
|
||
|
if allow_password_sign_up is not None:
|
||
|
payload['allowPasswordSignup'] = _auth_utils.validate_boolean(
|
||
|
allow_password_sign_up, 'allowPasswordSignup')
|
||
|
if enable_email_link_sign_in is not None:
|
||
|
payload['enableEmailLinkSignin'] = _auth_utils.validate_boolean(
|
||
|
enable_email_link_sign_in, 'enableEmailLinkSignin')
|
||
|
|
||
|
try:
|
||
|
body = self.client.body('post', '/tenants', json=payload)
|
||
|
except requests.exceptions.RequestException as error:
|
||
|
raise _auth_utils.handle_auth_backend_error(error)
|
||
|
else:
|
||
|
return Tenant(body)
|
||
|
|
||
|
def update_tenant(
|
||
|
self, tenant_id, display_name=None, allow_password_sign_up=None,
|
||
|
enable_email_link_sign_in=None):
|
||
|
"""Updates the specified tenant with the given parameters."""
|
||
|
if not isinstance(tenant_id, str) or not tenant_id:
|
||
|
raise ValueError('Tenant ID must be a non-empty string.')
|
||
|
|
||
|
payload = {}
|
||
|
if display_name is not None:
|
||
|
payload['displayName'] = _validate_display_name(display_name)
|
||
|
if allow_password_sign_up is not None:
|
||
|
payload['allowPasswordSignup'] = _auth_utils.validate_boolean(
|
||
|
allow_password_sign_up, 'allowPasswordSignup')
|
||
|
if enable_email_link_sign_in is not None:
|
||
|
payload['enableEmailLinkSignin'] = _auth_utils.validate_boolean(
|
||
|
enable_email_link_sign_in, 'enableEmailLinkSignin')
|
||
|
|
||
|
if not payload:
|
||
|
raise ValueError('At least one parameter must be specified for update.')
|
||
|
|
||
|
url = '/tenants/{0}'.format(tenant_id)
|
||
|
update_mask = ','.join(_auth_utils.build_update_mask(payload))
|
||
|
params = 'updateMask={0}'.format(update_mask)
|
||
|
try:
|
||
|
body = self.client.body('patch', url, json=payload, params=params)
|
||
|
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)
|
||
|
|
||
|
def list_tenants(self, page_token=None, max_results=_MAX_LIST_TENANTS_RESULTS):
|
||
|
"""Retrieves a batch of tenants."""
|
||
|
if page_token is not None:
|
||
|
if not isinstance(page_token, str) or not page_token:
|
||
|
raise ValueError('Page token must be a non-empty string.')
|
||
|
if not isinstance(max_results, int):
|
||
|
raise ValueError('Max results must be an integer.')
|
||
|
if max_results < 1 or max_results > _MAX_LIST_TENANTS_RESULTS:
|
||
|
raise ValueError(
|
||
|
'Max results must be a positive integer less than or equal to '
|
||
|
'{0}.'.format(_MAX_LIST_TENANTS_RESULTS))
|
||
|
|
||
|
payload = {'pageSize': max_results}
|
||
|
if page_token:
|
||
|
payload['pageToken'] = page_token
|
||
|
try:
|
||
|
return self.client.body('get', '/tenants', params=payload)
|
||
|
except requests.exceptions.RequestException as error:
|
||
|
raise _auth_utils.handle_auth_backend_error(error)
|
||
|
|
||
|
|
||
|
class ListTenantsPage:
|
||
|
"""Represents a page of tenants fetched from a Firebase project.
|
||
|
|
||
|
Provides methods for traversing tenants included in this page, as well as retrieving
|
||
|
subsequent pages of tenants. The iterator returned by ``iterate_all()`` can be used to iterate
|
||
|
through all tenants in the Firebase project starting from this page.
|
||
|
"""
|
||
|
|
||
|
def __init__(self, download, page_token, max_results):
|
||
|
self._download = download
|
||
|
self._max_results = max_results
|
||
|
self._current = download(page_token, max_results)
|
||
|
|
||
|
@property
|
||
|
def tenants(self):
|
||
|
"""A list of ``ExportedUserRecord`` instances available in this page."""
|
||
|
return [Tenant(data) for data in self._current.get('tenants', [])]
|
||
|
|
||
|
@property
|
||
|
def next_page_token(self):
|
||
|
"""Page token string for the next page (empty string indicates no more pages)."""
|
||
|
return self._current.get('nextPageToken', '')
|
||
|
|
||
|
@property
|
||
|
def has_next_page(self):
|
||
|
"""A boolean indicating whether more pages are available."""
|
||
|
return bool(self.next_page_token)
|
||
|
|
||
|
def get_next_page(self):
|
||
|
"""Retrieves the next page of tenants, if available.
|
||
|
|
||
|
Returns:
|
||
|
ListTenantsPage: Next page of tenants, or None if this is the last page.
|
||
|
"""
|
||
|
if self.has_next_page:
|
||
|
return ListTenantsPage(self._download, self.next_page_token, self._max_results)
|
||
|
return None
|
||
|
|
||
|
def iterate_all(self):
|
||
|
"""Retrieves an iterator for tenants.
|
||
|
|
||
|
Returned iterator will iterate through all the tenants in the Firebase project
|
||
|
starting from this page. The iterator will never buffer more than one page of tenants
|
||
|
in memory at a time.
|
||
|
|
||
|
Returns:
|
||
|
iterator: An iterator of Tenant instances.
|
||
|
"""
|
||
|
return _TenantIterator(self)
|
||
|
|
||
|
|
||
|
class _TenantIterator:
|
||
|
"""An iterator that allows iterating over tenants.
|
||
|
|
||
|
This implementation loads a page of tenants into memory, and iterates on them. When the whole
|
||
|
page has been traversed, it loads another page. This class never keeps more than one page
|
||
|
of entries in memory.
|
||
|
"""
|
||
|
|
||
|
def __init__(self, current_page):
|
||
|
if not current_page:
|
||
|
raise ValueError('Current page must not be None.')
|
||
|
self._current_page = current_page
|
||
|
self._index = 0
|
||
|
|
||
|
def next(self):
|
||
|
if self._index == len(self._current_page.tenants):
|
||
|
if self._current_page.has_next_page:
|
||
|
self._current_page = self._current_page.get_next_page()
|
||
|
self._index = 0
|
||
|
if self._index < len(self._current_page.tenants):
|
||
|
result = self._current_page.tenants[self._index]
|
||
|
self._index += 1
|
||
|
return result
|
||
|
raise StopIteration
|
||
|
|
||
|
def __next__(self):
|
||
|
return self.next()
|
||
|
|
||
|
def __iter__(self):
|
||
|
return self
|
||
|
|
||
|
|
||
|
def _validate_display_name(display_name):
|
||
|
if not isinstance(display_name, str):
|
||
|
raise ValueError('Invalid type for displayName')
|
||
|
if not _DISPLAY_NAME_PATTERN.search(display_name):
|
||
|
raise ValueError(
|
||
|
'displayName must start with a letter and only consist of letters, digits and '
|
||
|
'hyphens with 4-20 characters.')
|
||
|
return display_name
|