diff --git a/docs/en_US/release_notes_6_11.rst b/docs/en_US/release_notes_6_11.rst index 496b37962..0be343b2c 100644 --- a/docs/en_US/release_notes_6_11.rst +++ b/docs/en_US/release_notes_6_11.rst @@ -36,3 +36,4 @@ Bug fixes | `Issue #7461 `_ - Fixed an issue where the connection wasn't being closed when the user switched to a new connection and closed the query tool. | `Issue #7468 `_ - Skip the history records if the JSON info can't be parsed instead of showing 'No history'. | `Issue #7502 `_ - Fixed an issue where an error message is displayed when creating the new database. + | `Issue #7506 `_ - Fixed permission denied error when deploying PostgreSQL in Azure using Docker. diff --git a/web/config.py b/web/config.py index c23c5a56c..309415129 100644 --- a/web/config.py +++ b/web/config.py @@ -690,6 +690,12 @@ KRB_AUTO_CREATE_USER = True KERBEROS_CCACHE_DIR = os.path.join(DATA_DIR, 'krbccache') +############################################################################# +# Create local directory to store azure credential cache +############################################################################# + +AZURE_CREDENTIAL_CACHE_DIR = os.path.join(DATA_DIR, 'azurecredentialcache') + ########################################################################## # OAuth2 Configuration ########################################################################## diff --git a/web/pgacloud/providers/azure.py b/web/pgacloud/providers/azure.py index ba1a109e6..fbfae9ded 100644 --- a/web/pgacloud/providers/azure.py +++ b/web/pgacloud/providers/azure.py @@ -14,13 +14,15 @@ from azure.mgmt.rdbms.postgresql_flexibleservers import \ from azure.mgmt.rdbms.postgresql_flexibleservers.models import Sku, SkuTier, \ CreateMode, Storage, Server, FirewallRule, HighAvailability from azure.identity import AzureCliCredential, InteractiveBrowserCredential, \ - AuthenticationRecord, TokenCachePersistenceOptions + AuthenticationRecord from azure.mgmt.resource import ResourceManagementClient from azure.core.exceptions import ResourceNotFoundError from providers._abstract import AbsProvider import os from utils.io import debug, error, output from utils.misc import get_my_ip, get_random_id +from pgadmin.misc.cloud.azure.azure_cache import load_persistent_cache, \ + TokenCachePersistenceOptions class AzureProvider(AbsProvider): @@ -36,6 +38,7 @@ class AzureProvider(AbsProvider): self._credentials = None self._authentication_record_json = None self._cli_credentials = None + self.azure_cred_cache_name = None # Get the credentials if 'AUTHENTICATION_RECORD_JSON' in os.environ: @@ -55,6 +58,9 @@ class AzureProvider(AbsProvider): if 'AZURE_DATABASE_PASSWORD' in os.environ: self._database_pass = os.environ['AZURE_DATABASE_PASSWORD'] + if 'AZURE_CRED_CACHE_NAME' in os.environ: + self.azure_cred_cache_name = os.environ['AZURE_CRED_CACHE_NAME'] + def init_args(self, parsers): """ Create the command line parser for this provider """ self.parser = parsers. \ @@ -162,16 +168,19 @@ class AzureProvider(AbsProvider): _credential = InteractiveBrowserCredential( tenant_id=self._tenant_id, timeout=180, - cache_persistence_options=TokenCachePersistenceOptions( - allow_unencrypted_storage=True - ), + _cache=load_persistent_cache( + TokenCachePersistenceOptions( + name=self.azure_cred_cache_name, + allow_unencrypted_storage=True)), authentication_record=deserialized_auth_record) else: _credential = InteractiveBrowserCredential( tenant_id=self._tenant_id, timeout=180, - cache_persistence_options=TokenCachePersistenceOptions( - allow_unencrypted_storage=True) + _cache=load_persistent_cache( + TokenCachePersistenceOptions( + name=self.azure_cred_cache_name, + allow_unencrypted_storage=True)) ) return _credential @@ -185,14 +194,10 @@ class AzureProvider(AbsProvider): if type in self._clients: return self._clients[type] - if type == 'postgresql': - client = PostgreSQLManagementClient(self._credentials, - self._subscription_id) - elif type == 'resource': - client = ResourceManagementClient(self._credentials, - self._subscription_id) - - self._clients[type] = client + self._clients['postgresql'] = PostgreSQLManagementClient( + self._credentials, self._subscription_id) + self._clients['resource'] = ResourceManagementClient( + self._credentials, self._subscription_id) return self._clients[type] diff --git a/web/pgadmin/misc/cloud/azure/__init__.py b/web/pgadmin/misc/cloud/azure/__init__.py index 693caa48c..0d49cd666 100644 --- a/web/pgadmin/misc/cloud/azure/__init__.py +++ b/web/pgadmin/misc/cloud/azure/__init__.py @@ -8,6 +8,9 @@ # ########################################################################## # Azure implementation +import random + +import config from pgadmin.misc.cloud.utils import _create_server, CloudProcessDesc from pgadmin.misc.bgprocess.processes import BatchProcess from pgadmin import make_json_response @@ -15,12 +18,16 @@ from pgadmin.utils import PgAdminModule from flask_security import login_required import simplejson as json from flask import session, current_app, request +from flask_login import current_user from config import root +from .azure_cache import load_persistent_cache, TokenCachePersistenceOptions +import os + from azure.mgmt.rdbms.postgresql_flexibleservers import \ PostgreSQLManagementClient -from azure.identity import AzureCliCredential, InteractiveBrowserCredential, \ - TokenCachePersistenceOptions, AuthenticationRecord +from azure.identity import AzureCliCredential, InteractiveBrowserCredential,\ + AuthenticationRecord from azure.mgmt.resource import ResourceManagementClient from azure.mgmt.subscription import SubscriptionClient from azure.mgmt.rdbms.postgresql_flexibleservers.models import \ @@ -239,6 +246,8 @@ class Azure: self.subscription_id = None self._availability_zone = None self._available_capabilities_list = [] + self.cache_name = None + self.cache_name = current_user.username + "_msal.cache" ########################################################################## # Azure Helper functions @@ -250,6 +259,7 @@ class Azure: :return: True if valid credentials else false """ status, identity = self._get_azure_credentials() + session['azure']['azure_cache_file_name'] = self.cache_name error = '' if not status: error = identity @@ -288,16 +298,18 @@ class Azure: _credential = InteractiveBrowserCredential( tenant_id=self._tenant_id, timeout=180, - cache_persistence_options=TokenCachePersistenceOptions( - allow_unencrypted_storage=True - ), + _cache=load_persistent_cache( + TokenCachePersistenceOptions( + name=self.cache_name, + allow_unencrypted_storage=True)), authentication_record=deserialized_auth_record) else: _credential = InteractiveBrowserCredential( tenant_id=self._tenant_id, timeout=180, - cache_persistence_options=TokenCachePersistenceOptions( - allow_unencrypted_storage=True) + _cache=load_persistent_cache(TokenCachePersistenceOptions( + name=self.cache_name, + allow_unencrypted_storage=True)) ) return _credential @@ -672,6 +684,7 @@ def deploy_on_azure(data): azure = session['azure']['azure_obj'] env['AZURE_SUBSCRIPTION_ID'] = azure.subscription_id env['AUTH_TYPE'] = data['secret']['auth_type'] + env['AZURE_CRED_CACHE_NAME'] = azure.cache_name if azure.authentication_record_json is not None: env['AUTHENTICATION_RECORD_JSON'] = \ azure.authentication_record_json @@ -684,14 +697,20 @@ def deploy_on_azure(data): p.set_env_variables(None, env=env) p.update_server_id(p.id, sid) p.start() - del session['azure']['azure_obj'] return True, {'label': _label, 'sid': sid} except Exception as e: current_app.logger.exception(e) return False, str(e) + finally: + del session['azure']['azure_obj'] def clear_azure_session(): """Clear session data.""" if 'azure' in session: + file_name = session['azure']['azure_cache_file_name'] + file = config.AZURE_CREDENTIAL_CACHE_DIR + '/' + file_name + # Delete cache file if exists + if os.path.exists(file): + os.remove(file) session.pop('azure') diff --git a/web/pgadmin/misc/cloud/azure/azure_cache.py b/web/pgadmin/misc/cloud/azure/azure_cache.py new file mode 100644 index 000000000..5b0eb9255 --- /dev/null +++ b/web/pgadmin/misc/cloud/azure/azure_cache.py @@ -0,0 +1,136 @@ +# ------------------------------------ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# ------------------------------------ +import logging +import os +import sys +from typing import TYPE_CHECKING +import config + + +import six + +if TYPE_CHECKING: + from typing import Any + import msal_extensions + +_LOGGER = logging.getLogger(__name__) + + +class TokenCachePersistenceOptions(object): + """Options for persistent token caching. + + Most credentials accept an instance of this class to configure persistent + token caching. The default values configure a credential to use a cache + shared with Microsoft developer tools and + :class:`~azure.identity.SharedTokenCacheCredential`. + To isolate a credential's data from other applications, + specify a `name` for the cache. + + By default, the cache is encrypted with the current platform's user data + protection API, and will raise an error when this is not available. + To configure the cache to fall back to an unencrypted file instead + of raising an error, specify `allow_unencrypted_storage=True`. + + .. warning:: The cache contains authentication secrets. If the cache is + not encrypted, protecting it is the application's responsibility. + A breach of its contents will fully compromise accounts. + + .. literalinclude:: ../tests/test_persistent_cache.py + :start-after: [START snippet] + :end-before: [END snippet] + :language: python + :caption: Configuring a credential for persistent caching + :dedent: 8 + + :keyword str name: name of the cache, used to isolate its data from other + applications. Defaults to the name of the cache shared by Microsoft + dev tools and :class:`~azure.identity.SharedTokenCacheCredential`. + :keyword bool allow_unencrypted_storage: whether the cache should fall + back to storing its data in plain text when + encryption isn't possible. False by default. Setting this to + True does not disable encryption. The cache will + always try to encrypt its data. + """ + + def __init__(self, **kwargs): + # type: (**Any) -> None + self.allow_unencrypted_storage = \ + kwargs.get("allow_unencrypted_storage", False) + self.name = kwargs.get("name", "msal.cache") + + +def load_persistent_cache(options): + # type: + # (TokenCachePersistenceOptions) -> msal_extensions.PersistedTokenCache + import msal_extensions + persistence = _get_persistence( + allow_unencrypted=options.allow_unencrypted_storage, + account_name="MSALCache", + cache_name=options.name + ) + return msal_extensions.PersistedTokenCache(persistence) + + +def _get_persistence(allow_unencrypted, account_name, cache_name): + # type: (bool, str, str) -> msal_extensions.persistence.BasePersistence + """Get an msal_extensions persistence instance for the current platform. + + On Windows the cache is a file protected by the Data Protection API. + On Linux and macOS the cache is stored by + libsecret and Keychain, respectively. On those platforms the cache uses + the modified timestamp of a file on disk to + decide whether to reload the cache. + + :param bool allow_unencrypted: when True, the cache will be kept in + plaintext should encryption be impossible in the + current environment + """ + import msal_extensions + cache_location = \ + os.path.join(config.AZURE_CREDENTIAL_CACHE_DIR, cache_name) + + if sys.platform.startswith("win") and "LOCALAPPDATA" in os.environ: + return \ + msal_extensions.FilePersistenceWithDataProtection(cache_location) + + if sys.platform.startswith("darwin"): + # the cache uses this file's modified timestamp + # to decide whether to reload + return msal_extensions.KeychainPersistence( + cache_location, + "Microsoft.Developer.IdentityService", + account_name) + + if sys.platform.startswith("linux"): + # The cache uses this file's modified timestamp to decide whether + # to reload. Note this path is the same as that of the plaintext + # fallback: a new encrypted cache will stomp an unencrypted cache. + + try: + return msal_extensions.LibsecretPersistence( + cache_location, cache_name, + {"MsalClientID": "Microsoft.Developer.IdentityService"}, + label=account_name + ) + except Exception as ex: # pylint:disable=broad-except + _LOGGER.debug( + 'msal-extensions is unable to encrypt ' + 'a persistent cache: "%s"', ex, exc_info=True) + if not allow_unencrypted: + error = ValueError( + "Cache encryption is impossible because libsecret " + "dependencies are not installed or are unusable," + " for example because no display is available " + "(as in an SSH session). The chained exception has" + ' more information. Specify ' + '"allow_unencrypted_storage=True" to store' + ' the cache unencrypted' + " instead of raising this exception." + ) + six.raise_from(error, ex) + return msal_extensions.FilePersistence(cache_location) + + raise NotImplementedError("A persistent cache is not " + "available in this environment.") diff --git a/web/pgadmin/misc/cloud/static/js/cloud.js b/web/pgadmin/misc/cloud/static/js/cloud.js index ac1711fd8..bf3f0e7b1 100644 --- a/web/pgadmin/misc/cloud/static/js/cloud.js +++ b/web/pgadmin/misc/cloud/static/js/cloud.js @@ -125,7 +125,7 @@ define('pgadmin.misc.cloud', [ hooks: { // Triggered when the dialog is closed onclose: function () { - if(event.target instanceof Object){ + if(event.target instanceof Object && event.target.className == 'ajs-close'){ const axiosApi = getApiInstance(); let _url = url_for('cloud.clear_cloud_session'); axiosApi.post(_url) diff --git a/web/pgadmin/setup/data_directory.py b/web/pgadmin/setup/data_directory.py index d73c9816f..c7c118c08 100644 --- a/web/pgadmin/setup/data_directory.py +++ b/web/pgadmin/setup/data_directory.py @@ -112,6 +112,22 @@ def create_app_data_directory(config): config.APP_VERSION)) exit(1) + # Create Azure Credential Cache directory (if not present). + try: + _create_directory_if_not_exists(config.AZURE_CREDENTIAL_CACHE_DIR) + except PermissionError as e: + print(FAILED_CREATE_DIR.format(config.AZURE_CREDENTIAL_CACHE_DIR, e)) + print( + "HINT : Create the directory {}, ensure it is writable by\n" + "'{}', and try again, or, create a config_local.py file\n" + " and override the AZURE_CREDENTIAL_CACHE_DIR setting per\n" + " https://www.pgadmin.org/docs/pgadmin4/{}/config_py.html". + format( + config.AZURE_CREDENTIAL_CACHE_DIR, + getpass.getuser(), + config.APP_VERSION)) + exit(1) + # Create Kerberos Credential Cache directory (if not present). if config.SERVER_MODE and KERBEROS in config.AUTHENTICATION_SOURCES: try: