mirror of https://github.com/apache/superset.git
Implement permission request/approve flow. (#1095)
* Implement permission request/approve flow * Address the comments. * Refactor the code to support multiple datasources. * Reformat the queries.
This commit is contained in:
parent
b855e2f1a6
commit
cbc70d3738
|
@ -0,0 +1,33 @@
|
||||||
|
"""Add access_request table to manage requests to access datastores.
|
||||||
|
|
||||||
|
Revision ID: 5e4a03ef0bf0
|
||||||
|
Revises: 41f6a59a61f2
|
||||||
|
Create Date: 2016-09-09 17:39:57.846309
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '5e4a03ef0bf0'
|
||||||
|
down_revision = 'b347b202819b'
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.create_table(
|
||||||
|
'access_request',
|
||||||
|
sa.Column('created_on', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('changed_on', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('datasource_type', sa.String(length=200), nullable=True),
|
||||||
|
sa.Column('datasource_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('changed_by_fk', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('created_by_fk', sa.Integer(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['changed_by_fk'], ['ab_user.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['created_by_fk'], ['ab_user.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_table('access_request')
|
|
@ -26,6 +26,7 @@ from flask import escape, g, Markup, request
|
||||||
from flask_appbuilder import Model
|
from flask_appbuilder import Model
|
||||||
from flask_appbuilder.models.mixins import AuditMixin
|
from flask_appbuilder.models.mixins import AuditMixin
|
||||||
from flask_appbuilder.models.decorators import renders
|
from flask_appbuilder.models.decorators import renders
|
||||||
|
from flask_appbuilder.security.sqla.models import Role, PermissionView
|
||||||
from flask_babel import lazy_gettext as _
|
from flask_babel import lazy_gettext as _
|
||||||
|
|
||||||
from pydruid.client import PyDruid
|
from pydruid.client import PyDruid
|
||||||
|
@ -702,6 +703,10 @@ class SqlaTable(Model, Queryable, AuditMixinNullable):
|
||||||
"[{obj.database}].[{obj.table_name}]"
|
"[{obj.database}].[{obj.table_name}]"
|
||||||
"(id:{obj.id})").format(obj=self)
|
"(id:{obj.id})").format(obj=self)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self):
|
||||||
|
return self.table_name
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def full_name(self):
|
def full_name(self):
|
||||||
return "[{obj.database}].[{obj.table_name}]".format(obj=self)
|
return "[{obj.database}].[{obj.table_name}]".format(obj=self)
|
||||||
|
@ -1202,6 +1207,7 @@ class DruidCluster(Model, AuditMixinNullable):
|
||||||
for datasource in self.get_datasources():
|
for datasource in self.get_datasources():
|
||||||
if datasource not in config.get('DRUID_DATA_SOURCE_BLACKLIST'):
|
if datasource not in config.get('DRUID_DATA_SOURCE_BLACKLIST'):
|
||||||
DruidDatasource.sync_to_db(datasource, self)
|
DruidDatasource.sync_to_db(datasource, self)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def perm(self):
|
def perm(self):
|
||||||
return "[{obj.cluster_name}].(id:{obj.id})".format(obj=self)
|
return "[{obj.cluster_name}].(id:{obj.id})".format(obj=self)
|
||||||
|
@ -2000,6 +2006,7 @@ class Query(Model):
|
||||||
'tempTable': self.tmp_table_name,
|
'tempTable': self.tmp_table_name,
|
||||||
'userId': self.user_id,
|
'userId': self.user_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self):
|
def name(self):
|
||||||
ts = datetime.now().isoformat()
|
ts = datetime.now().isoformat()
|
||||||
|
@ -2007,3 +2014,71 @@ class Query(Model):
|
||||||
tab = self.tab_name.replace(' ', '_').lower() if self.tab_name else 'notab'
|
tab = self.tab_name.replace(' ', '_').lower() if self.tab_name else 'notab'
|
||||||
tab = re.sub(r'\W+', '', tab)
|
tab = re.sub(r'\W+', '', tab)
|
||||||
return "sqllab_{tab}_{ts}".format(**locals())
|
return "sqllab_{tab}_{ts}".format(**locals())
|
||||||
|
|
||||||
|
|
||||||
|
class DatasourceAccessRequest(Model, AuditMixinNullable):
|
||||||
|
"""ORM model for the access requests for datasources and dbs."""
|
||||||
|
__tablename__ = 'access_request'
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
|
||||||
|
datasource_id = Column(Integer)
|
||||||
|
datasource_type = Column(String(200))
|
||||||
|
|
||||||
|
ROLES_BLACKLIST = set(['Admin', 'Alpha', 'Gamma', 'Public'])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cls_model(self):
|
||||||
|
return src_registry.sources[self.datasource_type]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def username(self):
|
||||||
|
return self.creator()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def datasource(self):
|
||||||
|
return self.get_datasource
|
||||||
|
|
||||||
|
@datasource.getter
|
||||||
|
@utils.memoized
|
||||||
|
def get_datasource(self):
|
||||||
|
ds = db.session.query(self.cls_model).filter_by(
|
||||||
|
id=self.datasource_id).first()
|
||||||
|
return ds
|
||||||
|
|
||||||
|
@property
|
||||||
|
def datasource_link(self):
|
||||||
|
return self.datasource.link
|
||||||
|
|
||||||
|
@property
|
||||||
|
def roles_with_datasource(self):
|
||||||
|
action_list = ''
|
||||||
|
pv = sm.find_permission_view_menu(
|
||||||
|
'datasource_access', self.datasource.perm)
|
||||||
|
for r in pv.role:
|
||||||
|
if r.name in self.ROLES_BLACKLIST:
|
||||||
|
continue
|
||||||
|
url = (
|
||||||
|
'/caravel/approve?datasource_type={self.datasource_type}&'
|
||||||
|
'datasource_id={self.datasource_id}&'
|
||||||
|
'created_by={self.created_by.username}&role_to_grant={r.name}'
|
||||||
|
.format(**locals())
|
||||||
|
)
|
||||||
|
href = '<a href="{}">Grant {} Role</a>'.format(url, r.name)
|
||||||
|
action_list = action_list + '<li>' + href + '</li>'
|
||||||
|
return '<ul>' + action_list + '</ul>'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def user_roles(self):
|
||||||
|
action_list = ''
|
||||||
|
for r in self.created_by.roles:
|
||||||
|
url = (
|
||||||
|
'/caravel/approve?datasource_type={self.datasource_type}&'
|
||||||
|
'datasource_id={self.datasource_id}&'
|
||||||
|
'created_by={self.created_by.username}&role_to_extend={r.name}'
|
||||||
|
.format(**locals())
|
||||||
|
)
|
||||||
|
href = '<a href="{}">Extend {} Role</a>'.format(url, r.name)
|
||||||
|
if r.name in self.ROLES_BLACKLIST:
|
||||||
|
href = "{} Role".format(r.name)
|
||||||
|
action_list = action_list + '<li>' + href + '</li>'
|
||||||
|
return '<ul>' + action_list + '</ul>'
|
||||||
|
|
|
@ -0,0 +1,24 @@
|
||||||
|
{% extends "caravel/basic.html" %}
|
||||||
|
{% block title %}{{ _("No Access!") }}{% endblock %}
|
||||||
|
{% block body %}
|
||||||
|
{% include "caravel/flash_wrapper.html" %}
|
||||||
|
<div class="container">
|
||||||
|
<h4>
|
||||||
|
{{ _("You do not have permissions to access the datasource %(name)s.",
|
||||||
|
name=datasource_name)
|
||||||
|
}}
|
||||||
|
</h4>
|
||||||
|
<div id="buttons">
|
||||||
|
<button onclick="window.location.href = '{{ request_access_url }}';"
|
||||||
|
id="request"
|
||||||
|
>
|
||||||
|
{{ _("Request Permissions") }}
|
||||||
|
</button>
|
||||||
|
<button onclick="window.location.href = '{{ slicemodelview_link }}';"
|
||||||
|
id="cancel"
|
||||||
|
>
|
||||||
|
{{ _("Cancel") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
|
@ -212,6 +212,32 @@ class JSONEncodedDict(TypeDecorator):
|
||||||
|
|
||||||
def init(caravel):
|
def init(caravel):
|
||||||
"""Inits the Caravel application with security roles and such"""
|
"""Inits the Caravel application with security roles and such"""
|
||||||
|
ADMIN_ONLY_VIEW_MENUES = set([
|
||||||
|
'ResetPasswordView',
|
||||||
|
'RoleModelView',
|
||||||
|
'Security',
|
||||||
|
'UserDBModelView',
|
||||||
|
'SQL Lab <span class="label label-danger">alpha</span>',
|
||||||
|
'AccessRequestsModelView',
|
||||||
|
])
|
||||||
|
|
||||||
|
ADMIN_ONLY_PERMISSIONS = set([
|
||||||
|
'can_sync_druid_source',
|
||||||
|
'can_approve',
|
||||||
|
])
|
||||||
|
|
||||||
|
ALPHA_ONLY_PERMISSIONS = set([
|
||||||
|
'all_datasource_access',
|
||||||
|
'can_add',
|
||||||
|
'can_download',
|
||||||
|
'can_delete',
|
||||||
|
'can_edit',
|
||||||
|
'can_save',
|
||||||
|
'datasource_access',
|
||||||
|
'database_access',
|
||||||
|
'muldelete',
|
||||||
|
])
|
||||||
|
|
||||||
db = caravel.db
|
db = caravel.db
|
||||||
models = caravel.models
|
models = caravel.models
|
||||||
config = caravel.app.config
|
config = caravel.app.config
|
||||||
|
@ -223,44 +249,34 @@ def init(caravel):
|
||||||
merge_perm(sm, 'all_datasource_access', 'all_datasource_access')
|
merge_perm(sm, 'all_datasource_access', 'all_datasource_access')
|
||||||
|
|
||||||
perms = db.session.query(ab_models.PermissionView).all()
|
perms = db.session.query(ab_models.PermissionView).all()
|
||||||
|
# set alpha and admin permissions
|
||||||
for perm in perms:
|
for perm in perms:
|
||||||
if (
|
if (
|
||||||
perm.permission and
|
perm.permission and
|
||||||
perm.permission.name in ('datasource_access', 'database_access')):
|
perm.permission.name in ('datasource_access', 'database_access')):
|
||||||
continue
|
continue
|
||||||
if perm.view_menu and perm.view_menu.name not in (
|
if (
|
||||||
'ResetPasswordView',
|
perm.view_menu and
|
||||||
'RoleModelView',
|
perm.view_menu.name not in ADMIN_ONLY_VIEW_MENUES and
|
||||||
'Security',
|
perm.permission and
|
||||||
'UserDBModelView',
|
perm.permission.name not in ADMIN_ONLY_PERMISSIONS):
|
||||||
'SQL Lab'):
|
|
||||||
|
|
||||||
sm.add_permission_role(alpha, perm)
|
sm.add_permission_role(alpha, perm)
|
||||||
sm.add_permission_role(admin, perm)
|
sm.add_permission_role(admin, perm)
|
||||||
|
|
||||||
gamma = sm.add_role("Gamma")
|
gamma = sm.add_role("Gamma")
|
||||||
public_role = sm.find_role("Public")
|
public_role = sm.find_role("Public")
|
||||||
public_role_like_gamma = \
|
public_role_like_gamma = \
|
||||||
public_role and config.get('PUBLIC_ROLE_LIKE_GAMMA', False)
|
public_role and config.get('PUBLIC_ROLE_LIKE_GAMMA', False)
|
||||||
|
|
||||||
|
# set gamma permissions
|
||||||
for perm in perms:
|
for perm in perms:
|
||||||
if (
|
if (
|
||||||
perm.view_menu and perm.view_menu.name not in (
|
perm.view_menu and
|
||||||
'ResetPasswordView',
|
perm.view_menu.name not in ADMIN_ONLY_VIEW_MENUES and
|
||||||
'RoleModelView',
|
|
||||||
'UserDBModelView',
|
|
||||||
'SQL Lab',
|
|
||||||
'Security') and
|
|
||||||
perm.permission and
|
perm.permission and
|
||||||
perm.permission.name not in (
|
perm.permission.name not in ADMIN_ONLY_PERMISSIONS and
|
||||||
'all_datasource_access',
|
perm.permission.name not in ALPHA_ONLY_PERMISSIONS):
|
||||||
'can_add',
|
|
||||||
'can_download',
|
|
||||||
'can_delete',
|
|
||||||
'can_edit',
|
|
||||||
'can_save',
|
|
||||||
'datasource_access',
|
|
||||||
'database_access',
|
|
||||||
'muldelete',
|
|
||||||
)):
|
|
||||||
sm.add_permission_role(gamma, perm)
|
sm.add_permission_role(gamma, perm)
|
||||||
if public_role_like_gamma:
|
if public_role_like_gamma:
|
||||||
sm.add_permission_role(public_role, perm)
|
sm.add_permission_role(public_role, perm)
|
||||||
|
|
170
caravel/views.py
170
caravel/views.py
|
@ -36,6 +36,7 @@ from caravel import (
|
||||||
appbuilder, cache, db, models, viz, utils, app,
|
appbuilder, cache, db, models, viz, utils, app,
|
||||||
sm, ascii_art, sql_lab, src_registry
|
sm, ascii_art, sql_lab, src_registry
|
||||||
)
|
)
|
||||||
|
from caravel.models import DatasourceAccessRequest as DAR
|
||||||
|
|
||||||
config = app.config
|
config = app.config
|
||||||
log_this = models.Log.log_this
|
log_this = models.Log.log_this
|
||||||
|
@ -74,6 +75,9 @@ class ListWidgetWithCheckboxes(ListWidget):
|
||||||
ALL_DATASOURCE_ACCESS_ERR = __(
|
ALL_DATASOURCE_ACCESS_ERR = __(
|
||||||
"This endpoint requires the `all_datasource_access` permission")
|
"This endpoint requires the `all_datasource_access` permission")
|
||||||
DATASOURCE_MISSING_ERR = __("The datasource seems to have been deleted")
|
DATASOURCE_MISSING_ERR = __("The datasource seems to have been deleted")
|
||||||
|
ACCESS_REQUEST_MISSING_ERR = __(
|
||||||
|
"The access requests seem to have been deleted")
|
||||||
|
USER_MISSING_ERR = __("The user seems to have been deleted")
|
||||||
|
|
||||||
|
|
||||||
def get_database_access_error_msg(database_name):
|
def get_database_access_error_msg(database_name):
|
||||||
|
@ -81,13 +85,9 @@ def get_database_access_error_msg(database_name):
|
||||||
"`all_datasource_access` permission", name=database_name)
|
"`all_datasource_access` permission", name=database_name)
|
||||||
|
|
||||||
|
|
||||||
def get_datasource_access_error_msg(datasource):
|
def get_datasource_access_error_msg(datasource_name):
|
||||||
error = ("This endpoint requires the datasource %(name)s, database or "
|
return __("This endpoint requires the datasource %(name)s, database or "
|
||||||
"`all_datasource_access` permission")
|
"`all_datasource_access` permission", name=datasource_name)
|
||||||
if hasattr(datasource, 'table_name'):
|
|
||||||
return __(error, name=datasource.table_name)
|
|
||||||
else:
|
|
||||||
return __(error, name=datasource.datasource_name)
|
|
||||||
|
|
||||||
|
|
||||||
def get_error_msg():
|
def get_error_msg():
|
||||||
|
@ -628,6 +628,31 @@ appbuilder.add_view(
|
||||||
icon='fa-table',)
|
icon='fa-table',)
|
||||||
|
|
||||||
|
|
||||||
|
class AccessRequestsModelView(CaravelModelView, DeleteMixin):
|
||||||
|
datamodel = SQLAInterface(DAR)
|
||||||
|
list_columns = [
|
||||||
|
'username', 'user_roles', 'datasource_link',
|
||||||
|
'roles_with_datasource', 'created_on']
|
||||||
|
order_columns = ['username', 'datasource_link']
|
||||||
|
base_order = ('changed_on', 'desc')
|
||||||
|
label_columns = {
|
||||||
|
'username': _("User"),
|
||||||
|
'user_roles': _("User Roles"),
|
||||||
|
'database': _("Database URL"),
|
||||||
|
'datasource_link': _("Datasource"),
|
||||||
|
'roles_with_datasource': _("Roles to grant"),
|
||||||
|
'created_on': _("Created On"),
|
||||||
|
}
|
||||||
|
|
||||||
|
appbuilder.add_view(
|
||||||
|
AccessRequestsModelView,
|
||||||
|
"Access requests",
|
||||||
|
label=__("Access requests"),
|
||||||
|
category="Security",
|
||||||
|
category_label=__("Security"),
|
||||||
|
icon='fa-table',)
|
||||||
|
|
||||||
|
|
||||||
appbuilder.add_separator("Sources")
|
appbuilder.add_separator("Sources")
|
||||||
|
|
||||||
|
|
||||||
|
@ -968,6 +993,122 @@ appbuilder.add_view_no_menu(R)
|
||||||
|
|
||||||
class Caravel(BaseCaravelView):
|
class Caravel(BaseCaravelView):
|
||||||
"""The base views for Caravel!"""
|
"""The base views for Caravel!"""
|
||||||
|
@log_this
|
||||||
|
@has_access
|
||||||
|
@expose("/request_access_form/<datasource_type>/<datasource_id>/"
|
||||||
|
"<datasource_name>")
|
||||||
|
def request_access_form(
|
||||||
|
self, datasource_type, datasource_id, datasource_name):
|
||||||
|
request_access_url = (
|
||||||
|
'/caravel/request_access?datasource_type={}&datasource_id={}&'
|
||||||
|
'datasource_name=datasource_name'.format(
|
||||||
|
datasource_type, datasource_id, datasource_name)
|
||||||
|
)
|
||||||
|
return self.render_template(
|
||||||
|
'caravel/request_access.html',
|
||||||
|
request_access_url=request_access_url,
|
||||||
|
datasource_name=datasource_name,
|
||||||
|
slicemodelview_link='/slicemodelview/list/')
|
||||||
|
|
||||||
|
@log_this
|
||||||
|
@has_access
|
||||||
|
@expose("/request_access")
|
||||||
|
def request_access(self):
|
||||||
|
datasource_id = request.args.get('datasource_id')
|
||||||
|
datasource_type = request.args.get('datasource_type')
|
||||||
|
datasource_name = request.args.get('datasource_name')
|
||||||
|
session = db.session
|
||||||
|
|
||||||
|
duplicates = (
|
||||||
|
session.query(DAR)
|
||||||
|
.filter(
|
||||||
|
DAR.datasource_id == datasource_id,
|
||||||
|
DAR.datasource_type == datasource_type,
|
||||||
|
DAR.created_by_fk == g.user.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
if duplicates:
|
||||||
|
flash(__(
|
||||||
|
"You have already requested access to the datasource %(name)s",
|
||||||
|
name=datasource_name), "warning")
|
||||||
|
return redirect('/slicemodelview/list/')
|
||||||
|
|
||||||
|
access_request = DAR(datasource_id=datasource_id,
|
||||||
|
datasource_type=datasource_type)
|
||||||
|
db.session.add(access_request)
|
||||||
|
db.session.commit()
|
||||||
|
flash(__("Access to the datasource %(name)s was requested",
|
||||||
|
name=datasource_name), "info")
|
||||||
|
return redirect('/slicemodelview/list/')
|
||||||
|
|
||||||
|
@log_this
|
||||||
|
@has_access
|
||||||
|
@expose("/approve")
|
||||||
|
def approve(self):
|
||||||
|
datasource_type = request.args.get('datasource_type')
|
||||||
|
datasource_id = request.args.get('datasource_id')
|
||||||
|
created_by_username = request.args.get('created_by')
|
||||||
|
role_to_grant = request.args.get('role_to_grant')
|
||||||
|
role_to_extend = request.args.get('role_to_extend')
|
||||||
|
|
||||||
|
session = db.session
|
||||||
|
datasource_class = src_registry.sources[datasource_type]
|
||||||
|
datasource = session.query(datasource_class).filter_by(
|
||||||
|
id=datasource_id).first()
|
||||||
|
|
||||||
|
if not datasource:
|
||||||
|
flash(DATASOURCE_MISSING_ERR, "alert")
|
||||||
|
return json_error_response(DATASOURCE_MISSING_ERR)
|
||||||
|
|
||||||
|
requested_by = sm.find_user(username=created_by_username)
|
||||||
|
if not requested_by:
|
||||||
|
flash(USER_MISSING_ERR, "alert")
|
||||||
|
return json_error_response(USER_MISSING_ERR)
|
||||||
|
|
||||||
|
requests = (
|
||||||
|
session.query(DAR)
|
||||||
|
.filter(
|
||||||
|
DAR.datasource_id == datasource_id,
|
||||||
|
DAR.datasource_type == datasource_type,
|
||||||
|
DAR.created_by_fk == requested_by.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not requests:
|
||||||
|
flash(ACCESS_REQUEST_MISSING_ERR, "alert")
|
||||||
|
return json_error_response(ACCESS_REQUEST_MISSING_ERR)
|
||||||
|
|
||||||
|
# check if you can approve
|
||||||
|
if self.all_datasource_access() or g.user.id == datasource.owner_id:
|
||||||
|
# can by done by admin only
|
||||||
|
if role_to_grant:
|
||||||
|
role = sm.find_role(role_to_grant)
|
||||||
|
requested_by.roles.append(role)
|
||||||
|
flash(__(
|
||||||
|
"%(user)s was granted the role %(role)s that gives access "
|
||||||
|
"to the %(datasource)s",
|
||||||
|
user=requested_by.username,
|
||||||
|
role=role_to_grant,
|
||||||
|
datasource=datasource.full_name), "info")
|
||||||
|
|
||||||
|
if role_to_extend:
|
||||||
|
perm_view = sm.find_permission_view_menu(
|
||||||
|
'datasource_access', datasource.perm)
|
||||||
|
sm.add_permission_role(sm.find_role(role_to_extend), perm_view)
|
||||||
|
flash(__("Role %(r)s was extended to provide the access to"
|
||||||
|
" the datasource %(ds)s",
|
||||||
|
r=role_to_extend, ds=datasource.full_name), "info")
|
||||||
|
|
||||||
|
else:
|
||||||
|
flash(__("You have no permission to approve this request"),
|
||||||
|
"danger")
|
||||||
|
return redirect('/accessrequestsmodelview/list/')
|
||||||
|
for r in requests:
|
||||||
|
session.delete(r)
|
||||||
|
session.commit()
|
||||||
|
return redirect('/accessrequestsmodelview/list/')
|
||||||
|
|
||||||
@has_access
|
@has_access
|
||||||
@expose("/explore/<datasource_type>/<datasource_id>/<slice_id>/")
|
@expose("/explore/<datasource_type>/<datasource_id>/<slice_id>/")
|
||||||
@expose("/explore/<datasource_type>/<datasource_id>/")
|
@expose("/explore/<datasource_type>/<datasource_id>/")
|
||||||
|
@ -976,12 +1117,7 @@ class Caravel(BaseCaravelView):
|
||||||
def explore(self, datasource_type, datasource_id, slice_id=None):
|
def explore(self, datasource_type, datasource_id, slice_id=None):
|
||||||
error_redirect = '/slicemodelview/list/'
|
error_redirect = '/slicemodelview/list/'
|
||||||
datasource_class = src_registry.sources[datasource_type]
|
datasource_class = src_registry.sources[datasource_type]
|
||||||
|
datasources = db.session.query(datasource_class).all()
|
||||||
datasources = (
|
|
||||||
db.session
|
|
||||||
.query(datasource_class)
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
datasources = sorted(datasources, key=lambda ds: ds.full_name)
|
datasources = sorted(datasources, key=lambda ds: ds.full_name)
|
||||||
datasource = [ds for ds in datasources if int(datasource_id) == ds.id]
|
datasource = [ds for ds in datasources if int(datasource_id) == ds.id]
|
||||||
datasource = datasource[0] if datasource else None
|
datasource = datasource[0] if datasource else None
|
||||||
|
@ -991,8 +1127,10 @@ class Caravel(BaseCaravelView):
|
||||||
return redirect(error_redirect)
|
return redirect(error_redirect)
|
||||||
|
|
||||||
if not self.datasource_access(datasource):
|
if not self.datasource_access(datasource):
|
||||||
flash(__(get_datasource_access_error_msg(datasource)), "danger")
|
flash(
|
||||||
return redirect(error_redirect)
|
__(get_datasource_access_error_msg(datasource.name)), "danger")
|
||||||
|
return redirect('caravel/request_access_form/{}/{}/{}'.format(
|
||||||
|
datasource_type, datasource_id, datasource.name))
|
||||||
|
|
||||||
request_args_multi_dict = request.args # MultiDict
|
request_args_multi_dict = request.args # MultiDict
|
||||||
|
|
||||||
|
@ -1566,7 +1704,7 @@ class Caravel(BaseCaravelView):
|
||||||
|
|
||||||
# Prevent exposing column fields to users that cannot access DB.
|
# Prevent exposing column fields to users that cannot access DB.
|
||||||
if not self.datasource_access(t.perm):
|
if not self.datasource_access(t.perm):
|
||||||
flash(get_datasource_access_error_msg(t), 'danger')
|
flash(get_datasource_access_error_msg(t.name), 'danger')
|
||||||
return redirect("/tablemodelview/list/")
|
return redirect("/tablemodelview/list/")
|
||||||
|
|
||||||
fields = ", ".join(
|
fields = ", ".join(
|
||||||
|
|
|
@ -10,7 +10,7 @@ import unittest
|
||||||
from flask_appbuilder.security.sqla import models as ab_models
|
from flask_appbuilder.security.sqla import models as ab_models
|
||||||
|
|
||||||
import caravel
|
import caravel
|
||||||
from caravel import app, db, models, utils, appbuilder
|
from caravel import app, db, models, utils, appbuilder, sm
|
||||||
|
|
||||||
os.environ['CARAVEL_CONFIG'] = 'tests.caravel_test_config'
|
os.environ['CARAVEL_CONFIG'] = 'tests.caravel_test_config'
|
||||||
|
|
||||||
|
@ -22,7 +22,7 @@ class CaravelTestCase(unittest.TestCase):
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super(CaravelTestCase, self).__init__(*args, **kwargs)
|
super(CaravelTestCase, self).__init__(*args, **kwargs)
|
||||||
self.client = app.test_client()
|
self.client = app.test_client()
|
||||||
|
self.maxDiff = None
|
||||||
utils.init(caravel)
|
utils.init(caravel)
|
||||||
|
|
||||||
admin = appbuilder.sm.find_user('admin')
|
admin = appbuilder.sm.find_user('admin')
|
||||||
|
@ -46,6 +46,27 @@ class CaravelTestCase(unittest.TestCase):
|
||||||
appbuilder.sm.find_role('Alpha'),
|
appbuilder.sm.find_role('Alpha'),
|
||||||
password='general')
|
password='general')
|
||||||
|
|
||||||
|
# create druid cluster and druid datasources
|
||||||
|
session = db.session
|
||||||
|
cluster = session.query(models.DruidCluster).filter_by(
|
||||||
|
cluster_name="druid_test").first()
|
||||||
|
if not cluster:
|
||||||
|
cluster = models.DruidCluster(cluster_name="druid_test")
|
||||||
|
session.add(cluster)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
druid_datasource1 = models.DruidDatasource(
|
||||||
|
datasource_name='druid_ds_1',
|
||||||
|
cluster_name='druid_test'
|
||||||
|
)
|
||||||
|
session.add(druid_datasource1)
|
||||||
|
druid_datasource2 = models.DruidDatasource(
|
||||||
|
datasource_name='druid_ds_2',
|
||||||
|
cluster_name='druid_test'
|
||||||
|
)
|
||||||
|
session.add(druid_datasource2)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
utils.init(caravel)
|
utils.init(caravel)
|
||||||
|
|
||||||
def login(self, username='admin', password='general'):
|
def login(self, username='admin', password='general'):
|
||||||
|
@ -71,6 +92,14 @@ class CaravelTestCase(unittest.TestCase):
|
||||||
session.close()
|
session.close()
|
||||||
return query
|
return query
|
||||||
|
|
||||||
|
def get_access_requests(self, username, ds_type, ds_id):
|
||||||
|
return db.session.query(models.DatasourceAccessRequest).filter(
|
||||||
|
models.DatasourceAccessRequest.created_by_fk ==
|
||||||
|
sm.find_user(username=username).id,
|
||||||
|
models.DatasourceAccessRequest.datasource_type == ds_type,
|
||||||
|
models.DatasourceAccessRequest.datasource_id == ds_id
|
||||||
|
).all()
|
||||||
|
|
||||||
def logout(self):
|
def logout(self):
|
||||||
self.client.get('/logout/', follow_redirects=True)
|
self.client.get('/logout/', follow_redirects=True)
|
||||||
|
|
||||||
|
|
|
@ -17,7 +17,7 @@ from flask import escape
|
||||||
from flask_appbuilder.security.sqla import models as ab_models
|
from flask_appbuilder.security.sqla import models as ab_models
|
||||||
|
|
||||||
import caravel
|
import caravel
|
||||||
from caravel import app, db, models, utils, appbuilder, sm
|
from caravel import app, db, models, utils, appbuilder, sm, src_registry
|
||||||
from caravel.models import DruidDatasource
|
from caravel.models import DruidDatasource
|
||||||
|
|
||||||
from .base_tests import CaravelTestCase
|
from .base_tests import CaravelTestCase
|
||||||
|
@ -25,7 +25,6 @@ from .base_tests import CaravelTestCase
|
||||||
BASE_DIR = app.config.get("BASE_DIR")
|
BASE_DIR = app.config.get("BASE_DIR")
|
||||||
cli = imp.load_source('cli', BASE_DIR + "/bin/caravel")
|
cli = imp.load_source('cli', BASE_DIR + "/bin/caravel")
|
||||||
|
|
||||||
|
|
||||||
class CoreTests(CaravelTestCase):
|
class CoreTests(CaravelTestCase):
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
|
@ -45,11 +44,38 @@ class CoreTests(CaravelTestCase):
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
db.session.query(models.Query).delete()
|
db.session.query(models.Query).delete()
|
||||||
|
db.session.query(models.DatasourceAccessRequest).delete()
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def test_admin_only_permissions(self):
|
||||||
|
def assert_admin_permission_in(role_name, assert_func):
|
||||||
|
role = sm.find_role(role_name)
|
||||||
|
permissions = [p.permission.name for p in role.permissions]
|
||||||
|
assert_func('can_sync_druid_source', permissions)
|
||||||
|
assert_func('can_approve', permissions)
|
||||||
|
|
||||||
|
assert_admin_permission_in('Admin', self.assertIn)
|
||||||
|
assert_admin_permission_in('Alpha', self.assertNotIn)
|
||||||
|
assert_admin_permission_in('Gamma', self.assertNotIn)
|
||||||
|
|
||||||
|
def test_admin_only_menu_views(self):
|
||||||
|
def assert_admin_view_menus_in(role_name, assert_func):
|
||||||
|
role = sm.find_role(role_name)
|
||||||
|
view_menus = [p.view_menu.name for p in role.permissions]
|
||||||
|
assert_func('ResetPasswordView', view_menus)
|
||||||
|
assert_func('RoleModelView', view_menus)
|
||||||
|
assert_func('Security', view_menus)
|
||||||
|
assert_func('UserDBModelView', view_menus)
|
||||||
|
assert_func('SQL Lab <span class="label label-danger">alpha</span>',
|
||||||
|
view_menus)
|
||||||
|
assert_func('AccessRequestsModelView', view_menus)
|
||||||
|
|
||||||
|
assert_admin_view_menus_in('Admin', self.assertIn)
|
||||||
|
assert_admin_view_menus_in('Alpha', self.assertNotIn)
|
||||||
|
assert_admin_view_menus_in('Gamma', self.assertNotIn)
|
||||||
|
|
||||||
def test_save_slice(self):
|
def test_save_slice(self):
|
||||||
self.login(username='admin')
|
self.login(username='admin')
|
||||||
|
|
||||||
|
@ -161,7 +187,8 @@ class CoreTests(CaravelTestCase):
|
||||||
"flt_col_0=source&flt_op_0=in&flt_eq_0=&slice_id=78&slice_name="
|
"flt_col_0=source&flt_op_0=in&flt_eq_0=&slice_id=78&slice_name="
|
||||||
"Energy+Sankey&collapsed_fieldsets=&action=&datasource_name="
|
"Energy+Sankey&collapsed_fieldsets=&action=&datasource_name="
|
||||||
"energy_usage&datasource_id=1&datasource_type=table&"
|
"energy_usage&datasource_id=1&datasource_type=table&"
|
||||||
"previous_viz_type=sankey")
|
"previous_viz_type=sankey"
|
||||||
|
)
|
||||||
resp = self.client.post('/r/shortner/', data=data)
|
resp = self.client.post('/r/shortner/', data=data)
|
||||||
assert '/r/' in resp.data.decode('utf-8')
|
assert '/r/' in resp.data.decode('utf-8')
|
||||||
|
|
||||||
|
@ -210,8 +237,225 @@ class CoreTests(CaravelTestCase):
|
||||||
assert new_slice in dash.slices
|
assert new_slice in dash.slices
|
||||||
assert len(set(dash.slices)) == len(dash.slices)
|
assert len(set(dash.slices)) == len(dash.slices)
|
||||||
|
|
||||||
|
def test_approve(self):
|
||||||
|
session = db.session
|
||||||
|
sm.add_role('table_role')
|
||||||
|
self.login('admin')
|
||||||
|
|
||||||
|
def prepare_request(ds_type, ds_name, role):
|
||||||
|
ds_class = src_registry.sources[ds_type]
|
||||||
|
# TODO: generalize datasource names
|
||||||
|
if ds_type == 'table':
|
||||||
|
ds = session.query(ds_class).filter(
|
||||||
|
ds_class.table_name == ds_name).first()
|
||||||
|
else:
|
||||||
|
ds = session.query(ds_class).filter(
|
||||||
|
ds_class.datasource_name == ds_name).first()
|
||||||
|
ds_perm_view = sm.find_permission_view_menu(
|
||||||
|
'datasource_access', ds.perm)
|
||||||
|
sm.add_permission_role(sm.find_role(role), ds_perm_view)
|
||||||
|
access_request = models.DatasourceAccessRequest(
|
||||||
|
datasource_id=ds.id,
|
||||||
|
datasource_type=ds_type,
|
||||||
|
created_by_fk=sm.find_user(username='gamma').id,
|
||||||
|
)
|
||||||
|
session.add(access_request)
|
||||||
|
session.commit()
|
||||||
|
return access_request
|
||||||
|
|
||||||
|
EXTEND_ROLE_REQUEST = (
|
||||||
|
'/caravel/approve?datasource_type={}&datasource_id={}&'
|
||||||
|
'created_by={}&role_to_extend={}')
|
||||||
|
GRANT_ROLE_REQUEST = (
|
||||||
|
'/caravel/approve?datasource_type={}&datasource_id={}&'
|
||||||
|
'created_by={}&role_to_grant={}')
|
||||||
|
|
||||||
|
# Case 1. Grant new role to the user.
|
||||||
|
|
||||||
|
access_request1 = prepare_request(
|
||||||
|
'table', 'unicode_test', 'table_role')
|
||||||
|
ds_1_id = access_request1.datasource_id
|
||||||
|
self.client.get(GRANT_ROLE_REQUEST.format(
|
||||||
|
'table', ds_1_id, 'gamma', 'table_role'))
|
||||||
|
access_requests = self.get_access_requests('gamma', 'table', ds_1_id)
|
||||||
|
# request was removed
|
||||||
|
self.assertFalse(access_requests)
|
||||||
|
# user was granted table_role
|
||||||
|
user_roles = [r.name for r in sm.find_user('gamma').roles]
|
||||||
|
self.assertIn('table_role', user_roles)
|
||||||
|
|
||||||
|
# Case 2. Extend the role to have access to the table
|
||||||
|
|
||||||
|
access_request2 = prepare_request('table', 'long_lat', 'table_role')
|
||||||
|
ds_2_id = access_request2.datasource_id
|
||||||
|
long_lat_perm = access_request2.datasource.perm
|
||||||
|
|
||||||
|
self.client.get(EXTEND_ROLE_REQUEST.format(
|
||||||
|
'table', access_request2.datasource_id, 'gamma', 'table_role'))
|
||||||
|
access_requests = self.get_access_requests('gamma', 'table', ds_2_id)
|
||||||
|
# request was removed
|
||||||
|
self.assertFalse(access_requests)
|
||||||
|
# table_role was extended to grant access to the long_lat table/
|
||||||
|
table_role = sm.find_role('table_role')
|
||||||
|
perm_view = sm.find_permission_view_menu(
|
||||||
|
'datasource_access', long_lat_perm)
|
||||||
|
self.assertIn(perm_view, table_role.permissions)
|
||||||
|
|
||||||
|
# Case 3. Grant new role to the user to access the druid datasource.
|
||||||
|
|
||||||
|
sm.add_role('druid_role')
|
||||||
|
access_request3 = prepare_request('druid', 'druid_ds_1', 'druid_role')
|
||||||
|
self.client.get(GRANT_ROLE_REQUEST.format(
|
||||||
|
'druid', access_request3.datasource_id, 'gamma', 'druid_role'))
|
||||||
|
|
||||||
|
# user was granted table_role
|
||||||
|
user_roles = [r.name for r in sm.find_user('gamma').roles]
|
||||||
|
self.assertIn('druid_role', user_roles)
|
||||||
|
|
||||||
|
# Case 4. Extend the role to have access to the druid datasource
|
||||||
|
|
||||||
|
access_request4 = prepare_request('druid', 'druid_ds_2', 'druid_role')
|
||||||
|
druid_ds_2_perm = access_request4.datasource.perm
|
||||||
|
|
||||||
|
self.client.get(EXTEND_ROLE_REQUEST.format(
|
||||||
|
'druid', access_request4.datasource_id, 'gamma', 'druid_role'))
|
||||||
|
# druid_role was extended to grant access to the druid_access_ds_2
|
||||||
|
druid_role = sm.find_role('druid_role')
|
||||||
|
perm_view = sm.find_permission_view_menu(
|
||||||
|
'datasource_access', druid_ds_2_perm)
|
||||||
|
self.assertIn(perm_view, druid_role.permissions)
|
||||||
|
|
||||||
|
# cleanup
|
||||||
|
gamma_user = sm.find_user(username='gamma')
|
||||||
|
gamma_user.roles.remove(sm.find_role('druid_role'))
|
||||||
|
gamma_user.roles.remove(sm.find_role('table_role'))
|
||||||
|
session.delete(sm.find_role('druid_role'))
|
||||||
|
session.delete(sm.find_role('table_role'))
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
def test_request_access(self):
|
||||||
|
session = db.session
|
||||||
|
self.login(username='gamma')
|
||||||
|
gamma_user = sm.find_user(username='gamma')
|
||||||
|
sm.add_role('dummy_role')
|
||||||
|
gamma_user.roles.append(sm.find_role('dummy_role'))
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
ACCESS_REQUEST = (
|
||||||
|
'/caravel/request_access?datasource_type={}&datasource_id={}')
|
||||||
|
ROLE_EXTEND_LINK = (
|
||||||
|
'<a href="/caravel/approve?datasource_type={}&datasource_id={}&'
|
||||||
|
'created_by={}&role_to_extend={}">Extend {} Role</a>')
|
||||||
|
ROLE_GRANT_LINK = (
|
||||||
|
'<a href="/caravel/approve?datasource_type={}&datasource_id={}&'
|
||||||
|
'created_by={}&role_to_grant={}">Grant {} Role</a>')
|
||||||
|
|
||||||
|
# Case 1. Request table access, there are no roles have this table.
|
||||||
|
|
||||||
|
table1 = session.query(models.SqlaTable).filter_by(
|
||||||
|
table_name='random_time_series').first()
|
||||||
|
table_1_id = table1.id
|
||||||
|
|
||||||
|
# request access to the table
|
||||||
|
self.client.get(ACCESS_REQUEST.format('table', table_1_id))
|
||||||
|
|
||||||
|
access_request1 = self.get_access_requests(
|
||||||
|
'gamma', 'table', table_1_id)[0]
|
||||||
|
approve_link_1 = ROLE_EXTEND_LINK.format(
|
||||||
|
'table', table_1_id, 'gamma', 'dummy_role', 'dummy_role')
|
||||||
|
self.assertEqual(
|
||||||
|
access_request1.user_roles,
|
||||||
|
'<ul><li>Gamma Role</li><li>{}</li></ul>'.format(approve_link_1))
|
||||||
|
self.assertEqual(access_request1.roles_with_datasource, '<ul></ul>')
|
||||||
|
|
||||||
|
# Case 2. Duplicate request.
|
||||||
|
|
||||||
|
self.client.get(ACCESS_REQUEST.format('table', table_1_id))
|
||||||
|
access_requests_2 = self.get_access_requests(
|
||||||
|
'gamma', 'table', table_1_id)
|
||||||
|
self.assertEqual(len(access_requests_2), 1)
|
||||||
|
|
||||||
|
# Case 3. Request access, roles exist that contains the table.
|
||||||
|
|
||||||
|
# add table to the existing roles
|
||||||
|
table3 = session.query(models.SqlaTable).filter_by(
|
||||||
|
table_name='energy_usage').first()
|
||||||
|
table_3_id = table3.id
|
||||||
|
table3_perm = table3.perm
|
||||||
|
|
||||||
|
sm.add_role('energy_usage_role')
|
||||||
|
alpha_role = sm.find_role('Alpha')
|
||||||
|
sm.add_permission_role(
|
||||||
|
alpha_role,
|
||||||
|
sm.find_permission_view_menu('datasource_access', table3_perm))
|
||||||
|
sm.add_permission_role(
|
||||||
|
sm.find_role("energy_usage_role"),
|
||||||
|
sm.find_permission_view_menu('datasource_access', table3_perm))
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
self.client.get(ACCESS_REQUEST.format('table', table_3_id))
|
||||||
|
|
||||||
|
access_request3 = self.get_access_requests(
|
||||||
|
'gamma', 'table', table_3_id)[0]
|
||||||
|
approve_link_3 = ROLE_GRANT_LINK.format(
|
||||||
|
'table', table_3_id, 'gamma', 'energy_usage_role',
|
||||||
|
'energy_usage_role')
|
||||||
|
self.assertEqual(access_request3.roles_with_datasource,
|
||||||
|
'<ul><li>{}</li></ul>'.format(approve_link_3))
|
||||||
|
|
||||||
|
# Case 4. Request druid access, there are no roles have this table.
|
||||||
|
druid_ds_4 = session.query(models.DruidDatasource).filter_by(
|
||||||
|
datasource_name='druid_ds_1').first()
|
||||||
|
druid_ds_4_id = druid_ds_4.id
|
||||||
|
|
||||||
|
# request access to the table
|
||||||
|
self.client.get(ACCESS_REQUEST.format('druid', druid_ds_4_id))
|
||||||
|
access_request4 = self.get_access_requests(
|
||||||
|
'gamma', 'druid', druid_ds_4_id)[0]
|
||||||
|
approve_link_4 = ROLE_EXTEND_LINK.format(
|
||||||
|
'druid', druid_ds_4_id, 'gamma', 'dummy_role', 'dummy_role')
|
||||||
|
self.assertEqual(
|
||||||
|
access_request4.user_roles,
|
||||||
|
'<ul><li>Gamma Role</li><li>{}</li></ul>'.format(approve_link_4))
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
access_request4.roles_with_datasource,
|
||||||
|
'<ul></ul>'.format(access_request4.id))
|
||||||
|
|
||||||
|
# Case 5. Roles exist that contains the druid datasource.
|
||||||
|
# add druid ds to the existing roles
|
||||||
|
druid_ds_5 = session.query(models.DruidDatasource).filter_by(
|
||||||
|
datasource_name='druid_ds_2').first()
|
||||||
|
druid_ds_5_id = druid_ds_5.id
|
||||||
|
druid_ds_5_perm = druid_ds_5.perm
|
||||||
|
|
||||||
|
druid_ds_2_role = sm.add_role('druid_ds_2_role')
|
||||||
|
admin_role = sm.find_role('Admin')
|
||||||
|
sm.add_permission_role(
|
||||||
|
admin_role,
|
||||||
|
sm.find_permission_view_menu('datasource_access', druid_ds_5_perm))
|
||||||
|
sm.add_permission_role(
|
||||||
|
druid_ds_2_role,
|
||||||
|
sm.find_permission_view_menu('datasource_access', druid_ds_5_perm))
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
self.client.get(ACCESS_REQUEST.format('druid', druid_ds_5_id))
|
||||||
|
access_request5 = self.get_access_requests(
|
||||||
|
'gamma', 'druid', druid_ds_5_id)[0]
|
||||||
|
approve_link_5 = ROLE_GRANT_LINK.format(
|
||||||
|
'druid', druid_ds_5_id, 'gamma', 'druid_ds_2_role',
|
||||||
|
'druid_ds_2_role')
|
||||||
|
|
||||||
|
self.assertEqual(access_request5.roles_with_datasource,
|
||||||
|
'<ul><li>{}</li></ul>'.format(approve_link_5))
|
||||||
|
|
||||||
|
# cleanup
|
||||||
|
gamma_user = sm.find_user(username='gamma')
|
||||||
|
gamma_user.roles.remove(sm.find_role('dummy_role'))
|
||||||
|
session.commit()
|
||||||
|
|
||||||
def test_druid_sync_from_config(self):
|
def test_druid_sync_from_config(self):
|
||||||
|
self.login()
|
||||||
cluster = models.DruidCluster(cluster_name="new_druid")
|
cluster = models.DruidCluster(cluster_name="new_druid")
|
||||||
db.session.add(cluster)
|
db.session.add(cluster)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
Loading…
Reference in New Issue