Files
pgadmin4/web/pgadmin/misc/cloud/google/__init__.py
T
Dave Page 75dd304d25 fix(cloud): stop googleapiclient pulling in the system oauth2client (#10110) (#10112)
On packaged Linux installs the bundled venv is created with
--system-site-packages (issue #7173) so it can reach system packages such
as dbus-python. That also exposes the deprecated system oauth2client and
whatever pyOpenSSL ships alongside it. googleapiclient imports oauth2client
optionally, and on Ubuntu 24.04 that drags in a pyOpenSSL too old for our
bundled cryptography, aborting startup at blueprint registration with
"AttributeError: module 'lib' has no attribute 'GEN_EMAIL'".

pgAdmin only ever authenticates to Google via google-auth and
google-auth-oauthlib, so park a None sentinel under
sys.modules['oauth2client'] before importing googleapiclient in both the
web module and the standalone pgacloud provider. The optional import then
fails cleanly and googleapiclient falls back to google-auth.

Closes #10110
2026-07-24 19:02:29 +05:30

681 lines
25 KiB
Python

# ##########################################################################
# #
# # pgAdmin 4 - PostgreSQL Tools
# #
# # Copyright (C) 2013 - 2026, The pgAdmin Development Team
# # This software is released under the PostgreSQL Licence
# #
# ##########################################################################
# Google Cloud Deployment Implementation
import json
import os
import sys
from urllib.parse import unquote
from config import root
from pgadmin.utils.csrf import pgCSRFProtect
from pgadmin.utils.ajax import plain_text_response, unauthorized, \
make_json_response, bad_request
from pgadmin.misc.bgprocess import BatchProcess
from pgadmin.misc.cloud.utils import _create_server, CloudProcessDesc
from pgadmin.utils import PgAdminModule, filename_with_file_manager_path
from pgadmin.utils.text_sanitize import sanitize_external_text
from pgadmin.user_login_check import pga_login_required
from flask import session, current_app, request
from flask_babel import gettext as _
from oauthlib.oauth2 import AccessDeniedError
# pgAdmin only authenticates to Google via google-auth and
# google-auth-oauthlib, never the long-deprecated oauth2client.
# googleapiclient still tries to import oauth2client optionally, and on
# packaged installs the venv inherits the system site-packages
# (--system-site-packages, see issue #7173) where a stale oauth2client and
# an incompatible pyOpenSSL may be present. That optional import drags in
# the broken pyOpenSSL and aborts startup with "module 'lib' has no
# attribute 'GEN_EMAIL'". Blocking the module here makes googleapiclient
# fall back to google-auth cleanly. See issue #10110.
sys.modules.setdefault('oauth2client', None)
from googleapiclient import discovery
from googleapiclient.errors import HttpError
from google_auth_oauthlib.flow import InstalledAppFlow
from google.auth.transport.requests import Request
MODULE_NAME = 'google'
os.environ['OAUTHLIB_INSECURE_TRANSPORT'] = '1' # Required for Oauth2
class GooglePostgresqlModule(PgAdminModule):
"""Cloud module to deploy on Google Cloud"""
def get_exposed_url_endpoints(self):
return ['google.verify_credentials',
'google.projects',
'google.regions',
'google.database_versions',
'google.instance_types',
'google.availability_zones',
'google.verification_ack',
'google.callback']
blueprint = GooglePostgresqlModule(MODULE_NAME, __name__,
static_url_path='/misc/cloud/google')
def _get_google_from_session():
"""Build a Google instance from `session['google']['state']`.
Returns None when no Google state has been seeded yet — callers should
treat that as "auth not yet started."
The previous implementation persisted a live Google instance via an
unsafe serializer, which represented an insecure-deserialization
vector. We now store only a JSON-safe dict and rebuild on demand.
"""
google = session.get('google')
if not google:
return None
state = google.get('state')
if not state:
return None
return Google.from_state(state)
def _save_google_to_session(google_obj):
"""Persist the given Google instance's state into the session."""
if 'google' not in session:
session['google'] = {}
session['google']['state'] = google_obj.to_state()
@blueprint.route("/")
@pga_login_required
def index():
return bad_request(errormsg=_("This URL cannot be called directly."))
@blueprint.route('/verify_credentials/',
methods=['POST'], endpoint='verify_credentials')
@pga_login_required
def verify_credentials():
"""
Initiate process of authorisation for google oauth2
"""
data = json.loads(request.data)
client_secret_path = data['secret']['client_secret_file'] if \
'client_secret_file' in data['secret'] else None
status = False
error = None
res_data = {}
client_secret_path = unquote(client_secret_path)
try:
client_secret_path = \
filename_with_file_manager_path(client_secret_path)
except PermissionError as e:
return unauthorized(errormsg=sanitize_external_text(str(e)))
except Exception as e:
return bad_request(errormsg=sanitize_external_text(str(e)))
if client_secret_path and os.path.exists(client_secret_path):
with open(client_secret_path, 'r') as json_file:
client_config = json.load(json_file)
if 'google' not in session:
session['google'] = {}
# Reuse cached Google when client_config hasn't changed; otherwise
# start fresh (e.g., the user picked a different secret file).
cached_google = _get_google_from_session()
if cached_google is not None and \
session['google'].get('client_config') == client_config:
_google = cached_google
else:
_google = Google(client_config)
# get auth url
host_url = request.origin + '/'
if request.root_path != '':
host_url = host_url + request.root_path + '/'
auth_url, error_msg = _google.get_auth_url(host_url)
if error_msg:
error = error_msg
else:
status = True
res_data = {'auth_url': auth_url}
session['google']['client_config'] = client_config
_save_google_to_session(_google)
else:
error = 'Client secret path not found'
session.pop('google', None)
return make_json_response(
success=status,
errormsg=sanitize_external_text(error),
data=res_data)
@blueprint.route('/callback',
methods=['GET'], endpoint='callback')
@pgCSRFProtect.exempt
@pga_login_required
def callback():
"""
Call back function on google authentication response.
:return:
"""
google_obj = _get_google_from_session()
if google_obj is None:
return plain_text_response(
'Authentication session expired. Please try again.')
res = google_obj.callback(request)
_save_google_to_session(google_obj)
return plain_text_response(res)
@blueprint.route('/verification_ack',
methods=['GET'], endpoint='verification_ack')
@pga_login_required
def verification_ack():
"""
Checks for google oauth2 authorisation confirmation
:return:
"""
verified = False
google_obj = _get_google_from_session()
if google_obj is not None:
verified, error = google_obj.verification_ack()
_save_google_to_session(google_obj)
return make_json_response(
success=verified, errormsg=sanitize_external_text(error))
else:
return make_json_response(success=verified,
errormsg='Authentication is failed.')
@blueprint.route('/projects/',
methods=['GET'], endpoint='projects')
@pga_login_required
def get_projects():
"""
Lists the projects for authorized user
:return: list of projects
"""
google_obj = _get_google_from_session()
if google_obj is not None:
projects_list, error = google_obj.get_projects()
if error:
return bad_request(errormsg=sanitize_external_text(error))
return make_json_response(data=projects_list)
@blueprint.route('/regions/<project_id>',
methods=['GET'], endpoint='regions')
@pga_login_required
def get_regions(project_id):
"""
Lists regions based on project for authorized user
:param project_id: google project id
:return: google cloud sql region list
"""
google_obj = _get_google_from_session()
if google_obj is not None and project_id:
regions_list, error = google_obj.get_regions(project_id)
_save_google_to_session(google_obj)
if error:
return bad_request(errormsg=sanitize_external_text(error))
return make_json_response(data=regions_list)
else:
return make_json_response(data=[])
@blueprint.route('/availability_zones/<region>',
methods=['GET'], endpoint='availability_zones')
@pga_login_required
def get_availability_zones(region):
"""
List availability zones for specified region
:param region: google region
:return: google cloud sql availability zone list
"""
google_obj = _get_google_from_session()
if google_obj is not None and region:
availability_zone_list = google_obj.get_availability_zones(region)
return make_json_response(data=availability_zone_list)
else:
return make_json_response(data=[])
@blueprint.route('/instance_types/<project_id>/<region>/<instance_class>',
methods=['GET'], endpoint='instance_types')
@pga_login_required
def get_instance_types(project_id, region, instance_class):
"""
List the instances types for specified google project, region &
instance type
:param project_id: google project id
:param region: google cloud region
:param instance_class: google cloud sql instnace class
:return:
"""
google_obj = _get_google_from_session()
if google_obj is not None and project_id and region:
instance_types_dict = google_obj.get_instance_types(
project_id, region)
instance_types_list, error = (
instance_types_dict.get(instance_class, []))
if error:
return bad_request(errormsg=sanitize_external_text(error))
return make_json_response(data=instance_types_list)
else:
return make_json_response(data=[])
@blueprint.route('/database_versions/',
methods=['GET'], endpoint='database_versions')
@pga_login_required
def get_database_versions():
"""
Lists the postgresql database versions.
:return: PostgreSQL version list
"""
google_obj = _get_google_from_session()
if google_obj is not None:
db_version_list, error = google_obj.get_database_versions()
if error:
return bad_request(errormsg=sanitize_external_text(error))
return make_json_response(data=db_version_list)
else:
return make_json_response(data=[])
def deploy_on_google(data):
"""Deploy the Postgres instance on RDS."""
_cmd = 'python'
_cmd_script = '{0}/pgacloud/pgacloud.py'.format(root)
_label = data['instance_details']['name']
# Supported arguments for google cloud sql deployment
args = [_cmd_script,
data['cloud'],
'create-instance',
'--project', data['instance_details']['project'],
'--region', data['instance_details']['region'],
'--name', data['instance_details']['name'],
'--db-version', data['instance_details']['db_version'],
'--instance-type', data['instance_details']['instance_type'],
'--storage-type', data['instance_details']['storage_type'],
'--storage-size', str(data['instance_details']['storage_size']),
'--public-ip', str(data['instance_details']['public_ips']),
'--availability-zone',
data['instance_details']['availability_zone'],
'--high-availability',
str(data['instance_details']['high_availability']),
'--secondary-availability-zone',
data['instance_details']['secondary_availability_zone'],
]
_cmd_msg = '{0} {1} {2}'.format(_cmd, _cmd_script, ' '.join(args))
try:
sid = _create_server({
'gid': data['db_details']['gid'],
'name': data['instance_details']['name'],
'db': 'postgres',
'username': 'postgres',
'port': 5432,
'cloud_status': -1
})
p = BatchProcess(
desc=CloudProcessDesc(sid, _cmd_msg, data['cloud'],
data['instance_details']['name']),
cmd=_cmd,
args=args
)
# Set env variables for background process of deployment
env = dict()
google_obj = _get_google_from_session()
if google_obj is None or google_obj.credentials_json is None:
return False, None, 'Google credentials missing from session.'
env['GOOGLE_CREDENTIALS'] = json.dumps(google_obj.credentials_json)
if 'db_password' in data['db_details']:
env['GOOGLE_DATABASE_PASSWORD'] = data['db_details']['db_password']
p.set_env_variables(None, env=env)
p.update_server_id(p.id, sid)
p.start()
return True, p, {'label': _label, 'sid': sid}
except Exception as e:
current_app.logger.exception(e)
return False, None, str(e)
def clear_google_session():
"""Clear Google Session"""
if 'google' in session:
session.pop('google')
class Google:
def __init__(self, client_config=None):
# Google cloud sql api versions
self._cloud_resource_manager_api_version = 'v1'
self._sqladmin_api_version = 'v1'
self._compute_api_version = 'v1'
# Scope required for google cloud sql deployment
self._scopes = ['https://www.googleapis.com/auth/cloud-platform',
'https://www.googleapis.com/auth/sqlservice.admin']
# Instance classed
self._instance_classes = [{'label': 'Standard', 'value': 'standard'},
{'label': 'High Memory', 'value': 'highmem'},
{'label': 'Shared', 'value': 'shared'}]
self._client_config = client_config
self._credentials = None
self.credentials_json = None
self._project_id = None
self._regions = []
self._availability_zones = {}
self._verification_successful = False
self._verification_error = None
self._redirect_url = None
def to_state(self):
"""Serialize the persistable state of this Google instance to a
plain dict for storage in `flask.session`.
Live SDK objects (like `_credentials`) are intentionally NOT
included — they are rebuilt from `credentials_json` in
`from_state()`. Storing only data primitives lets the session
layer use a safe (non-binary-serializer) format.
"""
return {
'client_config': self._client_config,
'credentials_json': self.credentials_json,
'redirect_url': self._redirect_url,
'project_id': self._project_id,
'regions': self._regions,
'availability_zones': self._availability_zones,
'verification_successful': self._verification_successful,
'verification_error': self._verification_error,
}
@classmethod
def from_state(cls, state):
"""Rebuild a Google instance from a previously-serialized dict.
The reverse of `to_state()`. The Google SDK credentials object is
reconstructed from the stored token dict so subsequent API calls
work without forcing the user back through the OAuth2 flow.
"""
if not isinstance(state, dict):
return None
obj = cls(client_config=state.get('client_config'))
obj.credentials_json = state.get('credentials_json')
if obj.credentials_json:
# Rebuild google.oauth2.credentials.Credentials from the
# persisted token dict. Credentials() ignores unknown kwargs,
# so id_token (kept in the dict for API parity) is dropped.
from google.oauth2.credentials import Credentials
obj._credentials = Credentials(
token=obj.credentials_json.get('token'),
refresh_token=obj.credentials_json.get('refresh_token'),
token_uri=obj.credentials_json.get('token_uri'),
client_id=obj.credentials_json.get('client_id'),
client_secret=obj.credentials_json.get('client_secret'),
scopes=obj.credentials_json.get('scopes'),
)
obj._redirect_url = state.get('redirect_url')
obj._project_id = state.get('project_id')
obj._regions = state.get('regions', []) or []
obj._availability_zones = state.get('availability_zones', {}) or {}
obj._verification_successful = bool(
state.get('verification_successful', False))
obj._verification_error = state.get('verification_error')
return obj
def get_auth_url(self, host_url):
"""
Provides google authorisation url
:param host_url: Base url for hosting application
:return: authorisation url to complete authentication
"""
auth_url = None
error = None
# reset below variable to get latest values in fresh
# authentication call
self._verification_successful = False
self._verification_error = None
try:
self._redirect_url = host_url + 'google/callback'
flow = InstalledAppFlow.from_client_config(
client_config=self._client_config, scopes=self._scopes,
redirect_uri=self._redirect_url)
auth_url, state = flow.authorization_url(
prompt='select_account', access_type='offline',
include_granted_scopes='true')
session["state"] = state
except Exception as e:
error = str(e)
self._verification_error = error
return auth_url, error
def callback(self, flask_request):
"""
Callback function on completion of google authorisation request
:param flask_request:
:return: Success or error message
"""
try:
authorization_response = flask_request.url
if session['state'] != flask_request.args.get('state', None):
self._verification_successful = False,
self._verification_error = 'Invalid state parameter'
flow = InstalledAppFlow.from_client_config(
client_config=self._client_config, scopes=self._scopes,
redirect_uri=self._redirect_url)
flow.fetch_token(authorization_response=authorization_response)
self._credentials = flow.credentials
self.credentials_json = \
self._credentials_to_dict(self._credentials)
self._verification_successful = True
return 'The authentication flow has completed. ' \
'This window will be closed.'
except AccessDeniedError as er:
self._verification_successful = False
self._verification_error = er.error
if self._verification_error == 'access_denied':
self._verification_error = 'Access denied.'
return self._verification_error
@staticmethod
def _credentials_to_dict(credentials):
return {'token': credentials.token,
'refresh_token': credentials.refresh_token,
'token_uri': credentials.token_uri,
'client_id': credentials.client_id,
'client_secret': credentials.client_secret,
'scopes': credentials.scopes,
'id_token': credentials.id_token}
def verification_ack(self):
"""Check the Verification is done or not."""
return self._verification_successful, self._verification_error
def _get_credentials(self, scopes):
"""
Provides google credentials for google cloud sql api calls
:param scopes: Required scope of credentials
:return: google credential object
"""
if not self._credentials or not self._credentials.valid:
if self._credentials and self._credentials.expired and \
self._credentials.refresh_token and \
self._credentials.has_scopes(scopes):
self._credentials.refresh(Request())
return self._credentials
return self._credentials
def get_projects(self):
"""
List the google projects for authorised user
:return:
"""
projects = []
error = None
credentials = self._get_credentials(self._scopes)
service = discovery.build('cloudresourcemanager',
self._cloud_resource_manager_api_version,
credentials=credentials)
try:
req = service.projects().list()
res = req.execute()
for project in res.get('projects', []):
projects.append({'label': project['projectId'],
'value': project['projectId']})
except HttpError as e:
error = e.reason
except Exception as e:
error = str(e)
return projects, error
def get_regions(self, project):
"""
List regions for specified google cloud project
:param project: google cloud project id.
:return:
"""
self._project_id = project
credentials = self._get_credentials(self._scopes)
service = discovery.build('compute',
self._compute_api_version,
credentials=credentials)
error = None
try:
req = service.regions().list(project=project)
res = req.execute()
for item in res.get('items', []):
region_name = item['name']
self._regions.append(
{'label': region_name, 'value': region_name})
region_zones = item.get('zones', [])
region_zones = list(
map(lambda region: region.split('/')[-1], region_zones))
self._availability_zones[region_name] = region_zones
except HttpError as e:
error = e.reason
except Exception as e:
error = str(e)
return self._regions, error
def get_availability_zones(self, region):
"""
List availability zones in given google cloud region
:param region: google cloud region
:return:
"""
az_list = []
for az in self._availability_zones.get(region, []):
az_list.append({'label': az, 'value': az})
return az_list
def get_instance_types(self, project, region):
"""
Lists google cloud sql instance types.
:param project:
:param region:
:return:
"""
standard_instances = []
shared_instances = []
high_mem = []
instance_types = {}
error = None
credentials = self._get_credentials(self._scopes)
service = discovery.build('sqladmin',
self._sqladmin_api_version,
credentials=credentials)
try:
req = service.tiers().list(project=project)
res = req.execute()
for item in res.get('items', []):
if region in item.get('region', []):
if item['tier'].find('standard') != -1:
vcpu = item['tier'].split('-')[-1]
mem = round(int(item['RAM']) / (1024 * 1024))
label = (
vcpu + ' vCPU, ' + str(round(mem / 1024)) + ' GB')
value = 'db-custom-' + str(vcpu) + '-' + str(mem)
standard_instances.append(
{'label': label, 'value': value})
elif item['tier'].find('highmem') != -1:
vcpu = item['tier'].split('-')[-1]
mem = round(int(item['RAM']) / (1024 * 1024))
label = \
(vcpu + ' vCPU, ' + str(round(mem / 1024)) + ' GB')
value = 'db-custom-' + str(vcpu) + '-' + str(mem)
high_mem.append(
{'label': label, 'value': value})
else:
label = '1 vCPU, ' + str(
round((int(item['RAM']) / 1073741824), 2)) + ' GB'
value = item['tier']
shared_instances.append(
{'label': label, 'value': value})
instance_types = {'standard': standard_instances,
'highmem': high_mem,
'shared': shared_instances}
except HttpError as e:
error = e.reason
except Exception as e:
error = str(e)
return instance_types, error
def get_database_versions(self):
"""
Lists the PostgreSQL database versions
:return:
"""
pg_database_versions = []
database_versions = []
error = None
credentials = self._get_credentials(self._scopes)
service = discovery.build('sqladmin',
self._sqladmin_api_version,
credentials=credentials)
try:
req = service.flags().list()
res = req.execute()
for item in res.get('items', []):
if item.get('name', '') == 'max_parallel_workers':
pg_database_versions = item.get('appliesTo', [])
for version in pg_database_versions:
label = (version.title().split('_')[0])[0:7] \
+ 'SQL ' + version.split('_')[1]
database_versions.append({'label': label, 'value': version})
except HttpError as e:
error = e.reason
except Exception as e:
error = str(e)
return database_versions, error