Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added multi-tenancy support. #3729

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIT: our linter / PEP8 likes 2 empty lines before method and class definition. To lint locally, run flake8 superset/ from the root of the repo

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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIT: missing return char

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