Procházet zdrojové kódy

HUE-8740 [sql] Add prompt for credentials to MySQL

Romain Rigaux před 6 roky
rodič
revize
97b42ae9b1

+ 1 - 0
desktop/core/src/desktop/js/apps/notebook/app.js

@@ -1373,6 +1373,7 @@ huePubSub.subscribe('app.dom.loaded', app => {
 
     $(document).on('showAuthModal', (e, data) => {
       viewModel.authSessionUsername(window.LOGGED_USERNAME);
+      viewModel.authSessionMessage(data['message']);
       viewModel.authSessionPassword('');
       viewModel.authSessionType(data['type']);
       viewModel.authSessionCallback(data['callback']);

+ 1 - 0
desktop/core/src/desktop/js/apps/notebook/editorViewModel.js

@@ -223,6 +223,7 @@ class EditorViewModel {
 
     self.authSessionUsername = ko.observable(); // UI popup
     self.authSessionPassword = ko.observable();
+    self.authSessionMessage = ko.observable();
     self.authSessionType = ko.observable();
     self.authSessionCallback = ko.observable();
 

+ 4 - 1
desktop/core/src/desktop/js/apps/notebook/notebook.js

@@ -373,6 +373,9 @@ class Notebook {
             komapping.fromJS(data.session, {}, session);
             if (self.getSession(session.type()) == null) {
               self.addSession(session);
+            } else {
+              var _session = self.getSession(session.type());
+              komapping.fromJS(data.session, {}, _session);
             }
             $.each(self.getSnippets(session.type()), (index, snippet) => {
               snippet.status('ready');
@@ -381,7 +384,7 @@ class Notebook {
               setTimeout(callback, 500);
             }
           } else if (data.status == 401) {
-            $(document).trigger('showAuthModal', { type: session.type() });
+            $(document).trigger('showAuthModal', { type: session.type(), message: data.message });
           } else {
             fail(data.message);
           }

+ 1 - 1
desktop/core/src/desktop/js/apps/notebook/snippet.js

@@ -1572,7 +1572,7 @@ class Snippet {
       } else if (data.status == 401) {
         // Auth required
         self.status('expired');
-        $(document).trigger('showAuthModal', { type: self.type(), callback: self.execute });
+        $(document).trigger('showAuthModal', { type: self.type(), callback: self.execute, message: data.message });
       } else if (data.status == 1 || data.status == -1) {
         self.status('failed');
         const match = ERROR_REGEX.exec(data.message);

+ 6 - 1
desktop/libs/notebook/src/notebook/api.py

@@ -124,11 +124,16 @@ def _execute_notebook(request, notebook, snippet):
 
   try:
     try:
+      session = notebook.get('sessions') and notebook['sessions'][0] # Session reference for snippet execution without persisting it
       if historify:
         history = _historify(notebook, request.user)
         notebook = Notebook(document=history).get_data()
 
-      response['handle'] = get_api(request, snippet).execute(notebook, snippet)
+      interpreter = get_api(request, snippet)
+      if snippet['interface'] == 'sqlalchemy':
+        interpreter.options['session'] = session
+
+      response['handle'] = interpreter.execute(notebook, snippet)
 
       # Retrieve and remove the result from the handle
       if response['handle'].get('sync'):

+ 5 - 2
desktop/libs/notebook/src/notebook/connectors/base.py

@@ -41,7 +41,9 @@ class QueryExpired(Exception):
     self.message = message
 
 class AuthenticationRequired(Exception):
-  pass
+  def __init__(self, message=None):
+    super(AuthenticationRequired, self).__init__()
+    self.message = message
 
 class OperationTimeout(Exception):
   pass
@@ -122,7 +124,7 @@ class Notebook(object):
         return p1 + (value if value is not None else variable['meta'].get('placeholder',''))
 
       return re.sub("([^\\\\])\\$" + ("{(" if hasCurlyBracketParameters else "(") + variablesString + ")(=[^}]*)?" + ("}" if hasCurlyBracketParameters else ""), replace, statement_raw)
-      
+
     return statement_raw
 
   def add_hive_snippet(self, database, sql):
@@ -334,6 +336,7 @@ def get_api(request, snippet):
     interface = 'dataeng'
 
   LOG.info('Selected cluster %s %s interface %s' % (cluster_name, cluster, interface))
+  snippet['interface'] = interface
 
   if interface == 'hiveserver2':
     from notebook.connectors.hiveserver2 import HS2Api

+ 2 - 0
desktop/libs/notebook/src/notebook/connectors/jdbc_teradata.py

@@ -20,11 +20,13 @@ from librdbms.jdbc import query_and_fetch
 from notebook.connectors.jdbc import JdbcApi
 from notebook.connectors.jdbc import Assist
 
+
 class JdbcApiTeradata(JdbcApi):
 
   def _createAssist(self, db):
     return TeradataAssist(db)
 
+
 class TeradataAssist(Assist):
 
   def get_databases(self):

+ 67 - 8
desktop/libs/notebook/src/notebook/connectors/sqlalchemyapi.py

@@ -15,31 +15,68 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+'''
+SQL Alchemy offers native connections to databases via dialects https://docs.sqlalchemy.org/en/latest/dialects/.
+
+When the dialect of a paricular datavase is installed on the Hue API server, any of its URL connection strings should work.
+
+e.g.
+mysql://root:root@localhost:3306/hue
+
+To offer more self service capabilities, parts of the URL can be parameterized.
+
+Supported parameters are:
+
+* USER
+* PASSWORD
+
+e.g.
+mysql://${USER}:${PASSWORD}@localhost:3306/hue
+
+Parameters are not saved at any time in the Hue database. The are currently not even cached in the Hue process. The clients serves these parameters
+each time a query is sent.
+
+Note: the SQL Alchemy engine could leverage create_session() and cache the engine object (without its credentials) like in the jdbc.py interpreter.
+Note: this is currently supporting concurrent querying by one users as engine is a new object each time. Could use a thread global SQL Alchemy
+session at some point.
+Note: using the task server would not leverage any caching.
+'''
+
 import datetime
 import json
 import logging
 import uuid
+import sys
+
+from string import Template
 
-from desktop.lib import export_csvxls
-from desktop.lib.i18n import force_unicode
 from django.utils.translation import ugettext as _
+from sqlalchemy import create_engine, inspect
+from sqlalchemy.exc import OperationalError
 
+from desktop.lib import export_csvxls
+from desktop.lib.i18n import force_unicode
 from beeswax import data_export
 from librdbms.server import dbms
 
-from notebook.connectors.base import Api, QueryError, QueryExpired, _get_snippet_name
+from notebook.connectors.base import Api, QueryError, QueryExpired, _get_snippet_name, AuthenticationRequired
 from notebook.models import escape_rows
-from sqlalchemy import create_engine, inspect
-
 
 
-LOG = logging.getLogger(__name__)
 CONNECTION_CACHE = {}
+LOG = logging.getLogger(__name__)
+
 
 def query_error_handler(func):
   def decorator(*args, **kwargs):
     try:
       return func(*args, **kwargs)
+    except OperationalError, e:
+      message = str(e)
+      if '1045' in message: # 'Access denied' # MySQL
+        raise AuthenticationRequired(message=message)
+      else:
+        raise e
     except Exception, e:
       message = force_unicode(e)
       if 'Invalid query handle' in message or 'Invalid OperationHandle' in message:
@@ -51,13 +88,27 @@ def query_error_handler(func):
 
 
 class SqlAlchemyApi(Api):
+
   def __init__(self, user, interpreter=None):
+    self.user = user
     self.options = interpreter['options']
-    self.engine = create_engine(self.options['url'])
+    self.engine = None # Currently instantiated by an execute()
 
   @query_error_handler
   def execute(self, notebook, snippet):
     guid = uuid.uuid4().hex
+
+    if '${' in self.options['url']: # URL parameters substitution
+      vars = {'user': self.user.username}
+      for _prop in self.options['session']['properties']:
+        if _prop['name'] == 'user':
+          vars['USER'] = _prop['value']
+        if _prop['name'] == 'password':
+          vars['PASSWORD'] = _prop['value']
+
+    raw_url = Template(self.options['url'])
+    self.engine = create_engine(raw_url.safe_substitute(**vars))
+
     connection = self.engine.connect()
     result = connection.execute(snippet['statement'])
     cache = {
@@ -88,6 +139,7 @@ class SqlAlchemyApi(Api):
   def check_status(self, notebook, snippet):
     guid = snippet['result']['handle']['guid']
     connection = CONNECTION_CACHE.get(guid)
+
     if connection:
       return {'status': 'available'}
     else:
@@ -97,6 +149,7 @@ class SqlAlchemyApi(Api):
   def fetch_result(self, notebook, snippet, rows, start_over):
     guid = snippet['result']['handle']['guid']
     cache = CONNECTION_CACHE.get(guid)
+
     if cache:
       data = cache['result'].fetchmany(rows)
       meta = cache['meta']
@@ -104,6 +157,7 @@ class SqlAlchemyApi(Api):
     else:
       data = []
       meta = []
+
     return {
       'has_more': data and len(data) >= rows,
       'data': data if data else [],
@@ -156,24 +210,29 @@ class SqlAlchemyApi(Api):
   def download(self, notebook, snippet, format, user_agent=None):
     file_name = _get_snippet_name(notebook)
     guid = uuid.uuid4().hex
+
     connection = self.engine.connect()
     result = connection.execute(snippet['statement'])
+
     CONNECTION_CACHE[guid] = {
       'connection': connection,
       'result': result
     }
     db = FixedResult([col[0] if type(col) is dict or type(col) is tuple else col for col in result.cursor.description])
+
     def callback():
       connection = CONNECTION_CACHE.get(guid)
       if connection:
         connection['connection'].close()
         del CONNECTION_CACHE[guid]
+
     return data_export.download({'guid': guid}, format, db, id=snippet['id'], file_name=file_name, callback=callback)
 
 
   @query_error_handler
   def close_statement(self, snippet):
     result = {'status': -1}
+
     try:
       guid = snippet['result']['handle']['guid']
       connection = CONNECTION_CACHE.get('guid')
@@ -303,4 +362,4 @@ class FixedResult():
       data = connection['result'].fetchmany(rows)
       return FixedResultSet(self.metadata, data, data is not None and len(data) >= rows)
     else:
-      return FixedResultSet([], [])
+      return FixedResultSet([], [])

+ 2 - 0
desktop/libs/notebook/src/notebook/decorators.py

@@ -103,6 +103,8 @@ def api_error_handler(func):
         response['message'] = e.message
     except AuthenticationRequired, e:
       response['status'] = 401
+      if e.message and isinstance(e.message, basestring):
+        response['message'] = e.message
     except ValidationError, e:
       LOG.exception('Error validation %s' % func)
       response['status'] = -1

+ 7 - 0
desktop/libs/notebook/src/notebook/templates/editor_components.mako

@@ -2037,6 +2037,13 @@ ${ sqlSyntaxDropdown.sqlSyntaxDropdown() }
     <h2 class="modal-title">${_('Connect to the data source')}</h2>
   </div>
   <div class="modal-body">
+    <!-- ko if: $root.authSessionMessage() -->
+      <div class="row-fluid">
+        <div class="alert-warning">
+          <span data-bind="text: authSessionMessage"></span>
+        </div>
+      </div>
+    <!-- /ko -->
     <div class="row-fluid">
       <div class="span6">
         <div class="input-prepend">