diff --git a/docs/installation.rst b/docs/installation.rst index e04bd68747f0..9cd3de6cca29 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -284,6 +284,24 @@ on top of the **database**. For Superset to connect to a specific schema, there's a **schema** parameter you can set in the table form. +External Password store for SQLAlchemy connections +-------------------------------------------------- +It is possible to use an external store for you database passwords. This is +useful if you a running a custom secret distribution framework and do not wish +to store secrets in Superset's meta database. + +Example: +Write a function that takes a single argument of type ``sqla.engine.url`` and returns +the password for the given connection string. Then set ``SQLALCHEMY_CUSTOM_PASSWORD_STORE`` +in your config file to point to that function. :: + + def example_lookup_password(url): + secret = <> + return 'secret' + + SQLALCHEMY_CUSTOM_PASSWORD_STORE = example_lookup_password + + SSL Access to databases ----------------------- This example worked with a MySQL database that requires SSL. The configuration diff --git a/superset/config.py b/superset/config.py index 94d8dfc56a67..d7125a452d12 100644 --- a/superset/config.py +++ b/superset/config.py @@ -60,6 +60,15 @@ # SQLALCHEMY_DATABASE_URI = 'mysql://myapp@localhost/myapp' # SQLALCHEMY_DATABASE_URI = 'postgresql://root:password@localhost/myapp' +# In order to hook up a custom password store for all SQLACHEMY connections +# implement a function that takes a single argument of type 'sqla.engine.url', +# returns a password and set SQLALCHEMY_CUSTOM_PASSWORD_STORE. +# +# e.g.: +# def lookup_password(url): +# return 'secret' +# SQLALCHEMY_CUSTOM_PASSWORD_STORE = lookup_password + # The limit of queries fetched for query search QUERY_SEARCH_LIMIT = 1000 diff --git a/superset/models/core.py b/superset/models/core.py index 9a38b25a1f25..637ed091d950 100644 --- a/superset/models/core.py +++ b/superset/models/core.py @@ -561,6 +561,7 @@ class Database(Model, AuditMixinNullable): } """)) perm = Column(String(1000)) + custom_password_store = config.get('SQLALCHEMY_CUSTOM_PASSWORD_STORE') def __repr__(self): return self.verbose_name if self.verbose_name else self.database_name @@ -581,7 +582,7 @@ def backend(self): def set_sqlalchemy_uri(self, uri): password_mask = "X" * 10 conn = sqla.engine.url.make_url(uri) - if conn.password != password_mask: + if conn.password != password_mask and not self.custom_password_store: # do not over-write the password with the password mask self.password = conn.password conn.password = password_mask if conn.password else None @@ -725,7 +726,10 @@ def get_foreign_keys(self, table_name, schema=None): @property def sqlalchemy_uri_decrypted(self): conn = sqla.engine.url.make_url(self.sqlalchemy_uri) - conn.password = self.password + if self.custom_password_store: + conn.password = self.custom_password_store(conn) + else: + conn.password = self.password return str(conn) @property @@ -736,6 +740,7 @@ def get_perm(self): return ( "[{obj.database_name}].(id:{obj.id})").format(obj=self) + sqla.event.listen(Database, 'after_insert', set_perm) sqla.event.listen(Database, 'after_update', set_perm) diff --git a/tests/core_tests.py b/tests/core_tests.py index 34f30a14f573..159d16d3dde7 100644 --- a/tests/core_tests.py +++ b/tests/core_tests.py @@ -13,6 +13,7 @@ import unittest from flask import escape +import sqlalchemy as sqla from superset import db, utils, appbuilder, sm, jinja_context, sql_lab from superset.models import core as models @@ -296,6 +297,19 @@ def test_testconn(self): assert response.status_code == 200 assert response.headers['Content-Type'] == 'application/json' + def test_custom_password_store(self): + database = self.get_main_database(db.session) + conn_pre = sqla.engine.url.make_url(database.sqlalchemy_uri_decrypted) + + def custom_password_store(uri): + return "password_store_test" + + database.custom_password_store = custom_password_store + conn = sqla.engine.url.make_url(database.sqlalchemy_uri_decrypted) + if conn_pre.password: + assert conn.password == "password_store_test" + assert conn.password != conn_pre.password + def test_databaseview_edit(self, username='admin'): # validate that sending a password-masked uri does not over-write the decrypted uri self.login(username=username)