diff --git a/.gitignore b/.gitignore index df190c8abeafd..9039cdd886b8b 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,4 @@ superset/assets/version_info.json # IntelliJ *.iml +venv diff --git a/docs/installation.rst b/docs/installation.rst index 8cd253b15b362..8f0734bbd9c0a 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -196,6 +196,7 @@ of the parameters you can copy / paste in that configuration module: :: SUPERSET_WORKERS = 4 SUPERSET_WEBSERVER_PORT = 8088 + ENABLE_MULTI_TENANCY = False #--------------------------------------------------------- #--------------------------------------------------------- @@ -235,6 +236,16 @@ auth postback endpoint, you can add them to *WTF_CSRF_EXEMPT_LIST* WTF_CSRF_EXEMPT_LIST = [''] +Enable Multi Tenancy +--------------------- + +To achieve multi-tenancy follow following steps: + +* set *ENABLE_MULTI_TENANCY = True* in superset_config file. +* add column *tenant_id StringDataType(256)* in the tables or views in which you want multi-tenancy. This tenant_id is the same tenant_id as in ab_user table. +* Make sure that ab_user table have the column *tenant_id* else alter the table to add column tenant_id. +* if you want to enable multi-tenancy with *CUSTOM_SECURITY_MANAGER*, then your custom security manager class should be a subclass of *MultiTenantSecurityManager* class. + Database dependencies --------------------- diff --git a/superset/__init__.py b/superset/__init__.py index 1e563031df3e0..799e76a5515d8 100644 --- a/superset/__init__.py +++ b/superset/__init__.py @@ -19,6 +19,7 @@ from superset.connectors.connector_registry import ConnectorRegistry from superset import utils, config # noqa +from superset.multi_tenant import MultiTenantSecurityManager APP_DIR = os.path.dirname(__file__) CONFIG_MODULE = os.environ.get('SUPERSET_CONFIG', 'superset.config') @@ -144,13 +145,22 @@ class MyIndexView(IndexView): def index(self): return redirect('/superset/welcome') +security_manager_classs = app.config.get("CUSTOM_SECURITY_MANAGER") +if app.config.get("ENABLE_MULTI_TENANCY"): + if security_manager_classs is not None and \ + not issubclass(security_manager_classs, MultiTenantSecurityManager): + print("Not using the configured CUSTOM_SECURITY_MANAGER \ + as ENABLE_MULTI_TENANCY is True and CUSTOM_SECURITY_MANAGER \ + is not subclass of MultiTenantSecurityManager.") + print("Using MultiTenantSecurityManager as AppBuilder security_manager_class.") + security_manager_classs = MultiTenantSecurityManager appbuilder = AppBuilder( app, db.session, base_template='superset/base.html', indexview=MyIndexView, - security_manager_class=app.config.get("CUSTOM_SECURITY_MANAGER")) + security_manager_class=security_manager_classs) sm = appbuilder.sm diff --git a/superset/config.py b/superset/config.py index 4a12bff149e14..96a69b996c13a 100644 --- a/superset/config.py +++ b/superset/config.py @@ -48,6 +48,9 @@ SUPERSET_WEBSERVER_PORT = 8088 SUPERSET_WEBSERVER_TIMEOUT = 60 EMAIL_NOTIFICATIONS = False +ENABLE_MULTI_TENANCY = False +# CUSTOM_SECURITY_MANAGER will not be used if ENABLE_MULTI_TENANCY +# is True and it is not a subclass of MultiTenantSecurityManager class. CUSTOM_SECURITY_MANAGER = None SQLALCHEMY_TRACK_MODIFICATIONS = False # --------------------------------------------------------- diff --git a/superset/multi_tenant.py b/superset/multi_tenant.py new file mode 100644 index 0000000000000..9d9dce44a0ca1 --- /dev/null +++ b/superset/multi_tenant.py @@ -0,0 +1,37 @@ +from flask_appbuilder.security.sqla.manager import SecurityManager +from flask_appbuilder.security.sqla.models import User +from sqlalchemy import Column, Integer, ForeignKey, String, Sequence, Table +from sqlalchemy.orm import relationship, backref +from flask_appbuilder import Model +from flask_appbuilder.security.views import UserDBModelView +from flask_babel import lazy_gettext + +class MultiTenantUser(User): + tenant_id = Column(String(256)) + +class MultiTenantUserDBModelView(UserDBModelView): + show_fieldsets = [ + (lazy_gettext('User info'), + {'fields': ['username', 'active', 'roles', 'login_count', 'tenant_id']}), + (lazy_gettext('Personal Info'), + {'fields': ['first_name', 'last_name', 'email'], 'expanded': True}), + (lazy_gettext('Audit Info'), + {'fields': ['last_login', 'fail_login_count', 'created_on', + 'created_by', 'changed_on', 'changed_by'], 'expanded': False}), + ] + + user_show_fieldsets = [ + (lazy_gettext('User info'), + {'fields': ['username', 'active', 'roles', 'login_count']}), + (lazy_gettext('Personal Info'), + {'fields': ['first_name', 'last_name', 'email'], 'expanded': True}), + ] + + add_columns = ['first_name', 'last_name', 'username', 'active', 'email', 'roles', 'tenant_id', 'password', 'conf_password'] + list_columns = ['first_name', 'last_name', 'username', 'email', 'active', 'roles'] + edit_columns = ['first_name', 'last_name', 'username', 'active', 'email', 'roles', 'tenant_id'] + +# This will add multi tenant support in user model +class MultiTenantSecurityManager(SecurityManager): + user_model = MultiTenantUser + userdbmodelview = MultiTenantUserDBModelView \ No newline at end of file diff --git a/superset/security.py b/superset/security.py index 11b6b647c1f96..ebcda3b1e2efb 100644 --- a/superset/security.py +++ b/superset/security.py @@ -35,7 +35,8 @@ 'ResetPasswordView', 'RoleModelView', 'Security', - 'UserDBModelView', + 'UserDBModelView' if not conf.get('ENABLE_MULTI_TENANCY')\ + else 'MultiTenantUserDBModelView', } ADMIN_ONLY_PERMISSIONS = { diff --git a/superset/views/core.py b/superset/views/core.py index bd4d4e52ac361..01d554b19522f 100755 --- a/superset/views/core.py +++ b/superset/views/core.py @@ -701,7 +701,13 @@ def update_role(self): role_name = data['role_name'] role = sm.find_role(role_name) - role.user = existing_users + # This will fetch the User objects instead of sm.user_model as role.user + # expect the User object. + role_users = [] + for user in existing_users: + role_users.append(db.session.query(ab_models.User).filter( + ab_models.User.username == user.username).first()) + role.user = role_users sm.get_session.commit() return self.json_response({ 'role': role_name, diff --git a/superset/viz.py b/superset/viz.py index 025e9c52b0c52..f89d2cc6ee085 100644 --- a/superset/viz.py +++ b/superset/viz.py @@ -22,7 +22,7 @@ import pandas as pd import numpy as np -from flask import request +from flask import request, g from flask_babel import lazy_gettext as _ from markdown import markdown import simplejson as json @@ -125,6 +125,23 @@ def get_df(self, query_obj=None): df = df.fillna(fillna) return df + def append_tenant_filter(self, extras): + try: + current_user = g.user + except Exception as e: + return extras + if not (current_user and current_user.is_authenticated()): + return extras + # Add custom filter for non admin role only. + if not any([r.name in ['Admin'] for r in current_user.roles]): + # Fetch the custom filter from ab_user table + if self.datasource.get_col('tenant_id') is not None: + tenant_id = current_user.tenant_id or '' + multi_tenant_filter = "tenant_id='{}'".format(tenant_id) + extras['where'] = (multi_tenant_filter if extras['where'] == '' \ + else extras['where'] + ' AND ' + multi_tenant_filter) + return extras + def query_obj(self): """Building a query object""" form_data = self.form_data @@ -185,6 +202,9 @@ def query_obj(self): 'druid_time_origin': form_data.get("druid_time_origin", ''), } filters = form_data.get('filters', []) + # Added custom filter to support multi-tenanacy + if config.get('ENABLE_MULTI_TENANCY'): + extras = self.append_tenant_filter(extras) d = { 'granularity': granularity, 'from_dttm': from_dttm, diff --git a/tests/access_tests.py b/tests/access_tests.py index ec1ce7483dcea..6d06eba22c8f4 100644 --- a/tests/access_tests.py +++ b/tests/access_tests.py @@ -562,8 +562,12 @@ def test_update_role(self): follow_redirects=True ) update_role = sm.find_role(update_role_str) + update_role_users = [] + # Convert the User model to sm.user_model + for user in update_role.user: + update_role_users.append(sm.find_user(username=user.username)) self.assertEquals( - update_role.user, [sm.find_user(username='gamma')]) + update_role_users, [sm.find_user(username='gamma')]) self.assertEquals(resp.status_code, 201) resp = self.client.post( @@ -586,8 +590,12 @@ def test_update_role(self): ) self.assertEquals(resp.status_code, 201) update_role = sm.find_role(update_role_str) + update_role_users = [] + # Convert the User model to sm.user_model + for user in update_role.user: + update_role_users.append(sm.find_user(username=user.username)) self.assertEquals( - update_role.user, [ + update_role_users, [ sm.find_user(username='alpha'), sm.find_user(username='unknown'), ]) diff --git a/tests/core_tests.py b/tests/core_tests.py index c5335538dbe74..ff28b6564061e 100644 --- a/tests/core_tests.py +++ b/tests/core_tests.py @@ -119,7 +119,7 @@ def assert_admin_view_menus_in(role_name, assert_func): assert_func('ResetPasswordView', view_menus) assert_func('RoleModelView', view_menus) assert_func('Security', view_menus) - assert_func('UserDBModelView', view_menus) + assert_func(sm.userdbmodelview.__name__, view_menus) assert_func('SQL Lab', view_menus) assert_func('AccessRequestsModelView', view_menus) diff --git a/tests/security_tests.py b/tests/security_tests.py index c107024dc0c1a..1063e24b0beae 100644 --- a/tests/security_tests.py +++ b/tests/security_tests.py @@ -80,7 +80,7 @@ def assert_cannot_alpha(self, perm_set): self.assert_cannot_write('AccessRequestsModelView', perm_set) self.assert_cannot_write('Queries', perm_set) self.assert_cannot_write('RoleModelView', perm_set) - self.assert_cannot_write('UserDBModelView', perm_set) + self.assert_cannot_write(sm.userdbmodelview.__name__, perm_set) def assert_can_admin(self, perm_set): self.assert_can_all('DatabaseAsync', perm_set) @@ -88,7 +88,7 @@ def assert_can_admin(self, perm_set): self.assert_can_all('DruidClusterModelView', perm_set) self.assert_can_all('AccessRequestsModelView', perm_set) self.assert_can_all('RoleModelView', perm_set) - self.assert_can_all('UserDBModelView', perm_set) + self.assert_can_all(sm.userdbmodelview.__name__, perm_set) self.assertIn(('all_database_access', 'all_database_access'), perm_set) self.assertIn(('can_override_role_permissions', 'Superset'), perm_set) @@ -111,7 +111,7 @@ def test_is_admin_only(self): 'can_show', 'AccessRequestsModelView'))) self.assertTrue(security.is_admin_only( sm.find_permission_view_menu( - 'can_edit', 'UserDBModelView'))) + 'can_edit', sm.userdbmodelview.__name__))) self.assertTrue(security.is_admin_only( sm.find_permission_view_menu( 'can_approve', 'Superset')))