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

Dashboard level access control #5099

Closed
wants to merge 13 commits into from
Closed
Show file tree
Hide file tree
Changes from 9 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
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
bleach==2.1.2
boto3==1.4.7
celery==4.1.0
celery==4.1.1
colorama==0.3.9
cryptography==1.9
flask==0.12.2
Expand Down
2 changes: 2 additions & 0 deletions superset/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,8 @@
# This is useful if one wants to enable anonymous users to view
# dashboards. Explicit grant on specific datasets is still required.
PUBLIC_ROLE_LIKE_GAMMA = False
# This role will automatically get access to new dashboards created from a Slice
DEFAULT_DASHBOARD_ROLE = None

# ---------------------------------------------------
# Babel config for translations
Expand Down
22 changes: 22 additions & 0 deletions superset/migrations/versions/668b05dbfd7b_.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"""empty message

Revision ID: 668b05dbfd7b
Revises: ('e502db2af7be', '82c2867e532b')
Create Date: 2018-05-29 16:05:20.110737

"""

# revision identifiers, used by Alembic.
revision = '668b05dbfd7b'
down_revision = ('e502db2af7be', '82c2867e532b')

from alembic import op
import sqlalchemy as sa


def upgrade():
pass


def downgrade():
pass
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""dashboard role many to many

Revision ID: b21a4c518cab
Revises: e866bd2d4976
Create Date: 2018-03-08 16:30:36.924096

"""

# revision identifiers, used by Alembic.
revision = '76a4d742cf04'
down_revision = '5ccf602336a0'

from alembic import op
import sqlalchemy as sa


def upgrade():
op.create_table('dashboard_role',
Copy link
Member

Choose a reason for hiding this comment

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

Hey so the model so far has been to not create these many-to-many tables, and instead creating perms for each object (table, database, schema). This has pros/cons, one of the pros being to have the security model limited to FAB's RBAC tables. Meaning roles are only made out of users and perms. Then perms act as imperfect FKs to the object table. Cons is the fact that the join is imperfect as we want the perm object to be human readable, but contains the id.

I think I like the approach here, made possible by the fact that we now have SupersetSecurityManager and can specify rolemodelview. I can see how we'd move to do pattern for Databases, DruidDatasource, SqlaTable at some point. I'm not sure what others may think about this approach.

Copy link
Member

Choose a reason for hiding this comment

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

I'm a fan of proper foreign keys in join tables over FKs in permisssion names - it makes the data model more explicit and we can more easily enforce data consistency.

sa.Column('id', sa.Integer(), nullable=False),
sa.Column('role_id', sa.Integer(), nullable=True),
sa.Column('dashboard_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['dashboard_id'], [u'dashboards.id'], ),
sa.ForeignKeyConstraint(['role_id'], [u'ab_role.id'], ),
sa.PrimaryKeyConstraint('id'),
)


def downgrade():
op.drop_table('dashboard_role')
22 changes: 22 additions & 0 deletions superset/migrations/versions/82c2867e532b_.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"""empty message

Revision ID: 82c2867e532b
Revises: 76a4d742cf04
Create Date: 2018-05-29 15:21:07.127779

"""

# revision identifiers, used by Alembic.
revision = '82c2867e532b'
down_revision = '76a4d742cf04'

from alembic import op
import sqlalchemy as sa


def upgrade():
pass
Copy link
Member

Choose a reason for hiding this comment

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

If this migration is no-op, let's remove it before merge.



def downgrade():
pass
17 changes: 17 additions & 0 deletions superset/models/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,13 @@ def import_obj(cls, slc_to_import, slc_to_override, import_time=None):
Column('dashboard_id', Integer, ForeignKey('dashboards.id')),
)

dashboard_role = Table(
'dashboard_role', metadata,
Column('id', Integer, primary_key=True),
Column('role_id', Integer, ForeignKey('ab_role.id')),
Column('dashboard_id', Integer, ForeignKey('dashboards.id')),
)


class Dashboard(Model, AuditMixinNullable, ImportMixin):

Expand All @@ -323,13 +330,23 @@ class Dashboard(Model, AuditMixinNullable, ImportMixin):
slices = relationship(
'Slice', secondary=dashboard_slices, backref='dashboards')
owners = relationship(security_manager.user_model, secondary=dashboard_user)
roles = relationship(
security_manager.role_model,
secondary=dashboard_role,
backref='dashboards',
)

export_fields = ('dashboard_title', 'position_json', 'json_metadata',
'description', 'css', 'slug')

def __repr__(self):
return self.dashboard_title

@property
def perm(self):
return (
'[{obj.dashboard_title}](dash_id:{obj.id})').format(obj=self)

@property
def table_names(self):
# pylint: disable=no-member
Expand Down
30 changes: 30 additions & 0 deletions superset/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

from superset import sql_parse
from superset.connectors.connector_registry import ConnectorRegistry
from superset.superset_rmv import SupersetRoleModelView

READ_ONLY_MODEL_VIEWS = {
'DatabaseAsync',
Expand Down Expand Up @@ -76,11 +77,14 @@
'schema_access',
'datasource_access',
'metric_access',
'dashboard_access',
])


class SupersetSecurityManager(SecurityManager):

rolemodelview = SupersetRoleModelView

def get_schema_perm(self, database, schema):
if schema:
return '[{}].[{}]'.format(database, schema)
Expand All @@ -97,6 +101,10 @@ def all_datasource_access(self, user=None):
return self.can_access(
'all_datasource_access', 'all_datasource_access', user=user)

def all_dashboard_access(self, user=None):
return self.can_access(
'all_dashboard_access', 'all_dashboard_access', user=user)

def database_access(self, database, user=None):
return (
self.can_access(
Expand Down Expand Up @@ -163,6 +171,13 @@ def user_datasource_perms(self):
datasource_perms.add(perm.view_menu.name)
return datasource_perms

def dashboard_access(self, dashboard, user=None):
return (
self.all_dashboard_access(user=user) or
g.user in dashboard.owners or
self.can_access('dashboard_access', dashboard.perm)
)

def schemas_accessible_by_user(self, database, schemas):
from superset import db
from superset.connectors.sqla.models import SqlaTable
Expand Down Expand Up @@ -232,6 +247,7 @@ def create_custom_permissions(self):
# Global perms
self.merge_perm('all_datasource_access', 'all_datasource_access')
self.merge_perm('all_database_access', 'all_database_access')
self.merge_perm('all_dashboard_access', 'all_dashboard_access')

def create_missing_perms(self):
"""Creates missing perms for datasources, schemas and metrics"""
Expand Down Expand Up @@ -261,6 +277,11 @@ def merge_pv(view_menu, perm):
for database in databases:
merge_pv('database_access', database.perm)

logging.info('Creating missing dashboard permissions.')
dashboards = db.session.query(models.Dashboard).all()
for dashboard in dashboards:
merge_pv('dashboard_access', dashboard.perm)

logging.info('Creating missing metrics permissions')
metrics = []
for datasource_class in ConnectorRegistry.sources.values():
Expand Down Expand Up @@ -303,6 +324,15 @@ def sync_role_definitions(self):
if conf.get('PUBLIC_ROLE_LIKE_GAMMA', False):
self.set_role('Public', self.is_gamma_pvm)

default_dash_role = conf.get('DEFAULT_DASHBOARD_ROLE')
if default_dash_role:
role = self.find_role(default_dash_role)
if not role:
sesh = self.get_session
role = self.add_role(default_dash_role)
sesh.merge(role)
sesh.commit()

self.create_missing_perms()

# commit role and view menu updates
Expand Down
67 changes: 67 additions & 0 deletions superset/superset_rmv.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# -*- coding: utf-8 -*-
# pylint: disable=C,R,W
Copy link
Member

Choose a reason for hiding this comment

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

Please do not disable pylint on new files.

from __future__ import absolute_import
from __future__ import division
from __future__ import print_function
from __future__ import unicode_literals

import logging
import re

from flask_appbuilder.security.sqla.manager import SecurityManager
from flask_appbuilder.security.views import RoleModelView
from flask_babel import lazy_gettext as _


class SupersetRoleModelView(RoleModelView):
Copy link
Member

Choose a reason for hiding this comment

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

Let's update the file name to remove the abbreviation (superset_role_model_view.py rather than superset_rmv.py. Clarity > Brevity.


def add_dashboard_role(self, role, dash):
from superset import db

if dash not in role.dashboards:
try:
role.dashboards.append(dash)
db.session.merge(role)
db.session.commit()
except Exception as e:
logging.error(e)

def remove_dashboard_role(self, role, dash):
from superset import db

try:
role.dashboards.remove(dash)
db.session.merge(role)
db.session.commit()
except Exception as e:
logging.error(e)

def post_update(self, role):
from superset.models.core import Dashboard
from superset import db

role_dash_perms = []
for perm_view in role.permissions:
if perm_view.permission.name == 'dashboard_access':
m = re.search(r'dash_id\:(\d+)\)$', perm_view.view_menu.name)
if m:
dash_id = m.groups()[0]
role_dash_perms.append(int(dash_id))
try:
dash = db.session.query(Dashboard).filter_by(id=dash_id).first()
except Exception as e:
logging.error(e)

self.add_dashboard_role(role, dash)
else:
logging.error(
_("'dashboard_access name '{}' not as expected"
.format(perm_view.view_menu.name)))
role_dashboards = role.dashboards
for dash in role_dashboards:
if dash.id not in role_dash_perms:
self.remove_dashboard_role(role, dash)


class SupersetSecurityManager(SecurityManager):
Copy link
Member

Choose a reason for hiding this comment

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

SupersetSecurityManager is already defined in security.py and is the base people should use to override security-related things in their environment

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah, this code was left over from the merge (I had defined my own SupersetSecurityManager before it was added to security.py). I've removed it. This new rolemodelview is already defined in the class in security.py.

rolemodelview = SupersetRoleModelView
5 changes: 5 additions & 0 deletions superset/views/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,11 @@ def has_all_datasource_access(self):
self.has_role(['Admin', 'Alpha']) or
self.has_perm('all_datasource_access', 'all_datasource_access'))

def has_all_dashboard_access(self):
return (
self.has_role(['Admin', 'Alpha']) or
self.has_perm('all_dashboard_access', 'all_dashboard_access'))


class DatasourceFilter(SupersetFilter):
def apply(self, query, func): # noqa
Expand Down
Loading