Skip to content

Commit

Permalink
Added multi-tenancy support.
Browse files Browse the repository at this point in the history
Issue: #1089

To achieve multi-tenancy:
1. set "ENABLE_MULTI_TENANCY = True" in superset_config file.
2. add column tenant_id String(256) in the tables or views in which you want multi-tenancy.
3. if you are adding the multi-tenancy in existing project then
   make sure that ab_user table have the column tenant_id else alter the table.
4. if you want to enable multi-tenancy with CUSTOM_SECURITY_MANAGER,
   then your custom security manager class should be a subclass of MultiTenantSecurityManager class.

Added the documentation for multi-tenancy.

Fixed few typing errors. Also remove tenant_id from user view.
Fixes few test cases and role update api to support the custom user model.
  • Loading branch information
Mayank Thakur committed Oct 27, 2017
1 parent cbd0107 commit 5a063f3
Show file tree
Hide file tree
Showing 11 changed files with 107 additions and 10 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,4 @@ superset/assets/version_info.json

# IntelliJ
*.iml
venv
11 changes: 11 additions & 0 deletions docs/installation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
#---------------------------------------------------------

#---------------------------------------------------------
Expand Down Expand Up @@ -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
---------------------

Expand Down
12 changes: 11 additions & 1 deletion superset/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions superset/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ---------------------------------------------------------
Expand Down
37 changes: 37 additions & 0 deletions superset/multi_tenant.py
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion superset/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@
'ResetPasswordView',
'RoleModelView',
'Security',
'UserDBModelView',
'UserDBModelView' if not conf.get('ENABLE_MULTI_TENANCY')\
else 'MultiTenantUserDBModelView',
}

ADMIN_ONLY_PERMISSIONS = {
Expand Down
8 changes: 7 additions & 1 deletion superset/views/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
22 changes: 21 additions & 1 deletion superset/viz.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
12 changes: 10 additions & 2 deletions tests/access_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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'),
])
Expand Down
2 changes: 1 addition & 1 deletion tests/core_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 3 additions & 3 deletions tests/security_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,15 +80,15 @@ 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)
self.assert_can_all('DatabaseView', 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)
Expand All @@ -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')))
Expand Down

0 comments on commit 5a063f3

Please sign in to comment.