From 4c6026fddac9342c4667e01980cf6b0201af81f7 Mon Sep 17 00:00:00 2001 From: x4base Date: Fri, 10 Jun 2016 17:49:33 -0500 Subject: [PATCH] Add access control over metrics (#584) * Add the new field "is_restricted" to SqlMetric and DruidMetric * Add the access control on metrics * Add the more descriptions on is_restricted * Update docs/security.rst * Update docs/security.rst --- ...74f7aad_add_new_field_is_restricted_to_.py | 47 +++++++++++++++++++ caravel/models.py | 40 ++++++++++++++-- caravel/utils.py | 21 +++++++++ caravel/views.py | 37 +++++++++++---- docs/security.rst | 22 +++++++++ 5 files changed, 155 insertions(+), 12 deletions(-) create mode 100644 caravel/migrations/versions/d8bc074f7aad_add_new_field_is_restricted_to_.py diff --git a/caravel/migrations/versions/d8bc074f7aad_add_new_field_is_restricted_to_.py b/caravel/migrations/versions/d8bc074f7aad_add_new_field_is_restricted_to_.py new file mode 100644 index 0000000000000..883fd712c022a --- /dev/null +++ b/caravel/migrations/versions/d8bc074f7aad_add_new_field_is_restricted_to_.py @@ -0,0 +1,47 @@ +"""Add new field 'is_restricted' to SqlMetric and DruidMetric + +Revision ID: d8bc074f7aad +Revises: 1226819ee0e3 +Create Date: 2016-06-07 12:33:25.756640 + +""" + +# revision identifiers, used by Alembic. +revision = 'd8bc074f7aad' +down_revision = '1226819ee0e3' + +from alembic import op +import sqlalchemy as sa +from caravel import db +from caravel import models + + +def upgrade(): + with op.batch_alter_table('metrics', schema=None) as batch_op: + batch_op.add_column( + sa.Column('is_restricted', sa.Boolean(), nullable=True)) + + with op.batch_alter_table('sql_metrics', schema=None) as batch_op: + batch_op.add_column( + sa.Column('is_restricted', sa.Boolean(), nullable=True)) + + bind = op.get_bind() + session = db.Session(bind=bind) + + session.query(models.DruidMetric).update({ + 'is_restricted': False + }) + session.query(models.SqlMetric).update({ + 'is_restricted': False + }) + + session.commit() + session.close() + + +def downgrade(): + with op.batch_alter_table('sql_metrics', schema=None) as batch_op: + batch_op.drop_column('is_restricted') + + with op.batch_alter_table('metrics', schema=None) as batch_op: + batch_op.drop_column('is_restricted') diff --git a/caravel/models.py b/caravel/models.py index 3ee66847b2fce..4f9cca4a76b0c 100644 --- a/caravel/models.py +++ b/caravel/models.py @@ -38,9 +38,10 @@ from sqlalchemy.sql import table, literal_column, text, column from sqlalchemy_utils import EncryptedType -from caravel import app, db, get_session, utils +import caravel +from caravel import app, db, get_session, utils, sm from caravel.viz import viz_types -from caravel.utils import flasher +from caravel.utils import flasher, MetricPermException config = app.config @@ -858,12 +859,20 @@ class SqlMetric(Model, AuditMixinNullable): 'SqlaTable', backref='metrics', foreign_keys=[table_id]) expression = Column(Text) description = Column(Text) + is_restricted = Column(Boolean, default=False, nullable=True) @property def sqla_col(self): name = self.metric_name return literal_column(self.expression).label(name) + @property + def perm(self): + return ( + "{parent_name}.[{obj.metric_name}](id:{obj.id})" + ).format(obj=self, + parent_name=self.table.full_name) + class TableColumn(Model, AuditMixinNullable): @@ -1135,11 +1144,25 @@ def recursive_get_fields(_conf): conf.get('fn', "/"), conf.get('fields', []), conf.get('name', '')) + aggregations = { m.metric_name: m.json_obj for m in self.metrics if m.metric_name in all_metrics - } + } + + rejected_metrics = [ + m.metric_name for m in self.metrics + if m.is_restricted and + m.metric_name in aggregations.keys() and + not sm.has_access('metric_access', m.perm) + ] + + if rejected_metrics: + raise MetricPermException( + "Access to the metrics denied: " + ', '.join(rejected_metrics) + ) + granularity = granularity or "all" if granularity != "all": granularity = utils.parse_human_timedelta( @@ -1329,6 +1352,7 @@ class DruidMetric(Model, AuditMixinNullable): enable_typechecks=False) json = Column(Text) description = Column(Text) + is_restricted = Column(Boolean, default=False, nullable=True) @property def json_obj(self): @@ -1338,6 +1362,12 @@ def json_obj(self): obj = {} return obj + @property + def perm(self): + return ( + "{parent_name}.[{obj.metric_name}](id:{obj.id})" + ).format(obj=self, + parent_name=self.datasource.full_name) class DruidColumn(Model, AuditMixinNullable): @@ -1428,6 +1458,7 @@ def generate_metrics(self): 'fieldNames': [self.column_name]}) )) session = get_session() + new_metrics = [] for metric in metrics: m = ( session.query(M) @@ -1438,9 +1469,12 @@ def generate_metrics(self): ) metric.datasource_name = self.datasource_name if not m: + new_metrics.append(metric) session.add(metric) session.flush() + utils.init_metrics_perm(caravel, new_metrics) + class FavStar(Model): __tablename__ = 'favstar' diff --git a/caravel/utils.py b/caravel/utils.py index 36dc6eb431fcd..5b59e4d0f5844 100644 --- a/caravel/utils.py +++ b/caravel/utils.py @@ -29,6 +29,10 @@ class CaravelSecurityException(CaravelException): pass +class MetricPermException(Exception): + pass + + def flasher(msg, severity=None): """Flask's flash if available, logging call if not""" try: @@ -211,6 +215,23 @@ def init(caravel): for table_perm in table_perms: merge_perm(sm, 'datasource_access', table_perm) + init_metrics_perm(caravel) + + +def init_metrics_perm(caravel, metrics=None): + db = caravel.db + models = caravel.models + sm = caravel.appbuilder.sm + + if metrics is None: + metrics = [] + for model in [models.SqlMetric, models.DruidMetric]: + metrics += list(db.session.query(model).all()) + + metric_perms = [metric.perm for metric in metrics] + for metric_perm in metric_perms: + merge_perm(sm, 'metric_access', metric_perm) + def datetime_f(dttm): """Formats datetime to take less room when it is recent""" diff --git a/caravel/views.py b/caravel/views.py index 46798db5da7f7..d2e8e5b9db522 100644 --- a/caravel/views.py +++ b/caravel/views.py @@ -7,6 +7,7 @@ import json import logging import re +import sys import time import traceback from datetime import datetime @@ -29,6 +30,7 @@ from werkzeug.routing import BaseConverter from wtforms.validators import ValidationError +import caravel from caravel import appbuilder, db, models, viz, utils, app, sm, ascii_art config = app.config @@ -209,14 +211,19 @@ def post_update(self, col): class SqlMetricInlineView(CompactCRUDMixin, CaravelModelView): # noqa datamodel = SQLAInterface(models.SqlMetric) - list_columns = ['metric_name', 'verbose_name', 'metric_type'] + list_columns = ['metric_name', 'verbose_name', 'metric_type', + 'is_restricted'] edit_columns = [ 'metric_name', 'description', 'verbose_name', 'metric_type', - 'expression', 'table'] + 'expression', 'table', 'is_restricted'] description_columns = { 'expression': utils.markdown( "a valid SQL expression as supported by the underlying backend. " "Example: `count(DISTINCT userid)`", True), + 'is_restricted': _("Whether the access to this metric is restricted " + "to certain roles. Only roles with the permission " + "'metric access on XXX (the name of this metric)' " + "are allowed to access this metric"), } add_columns = edit_columns page_size = 500 @@ -228,15 +235,20 @@ class SqlMetricInlineView(CompactCRUDMixin, CaravelModelView): # noqa 'expression': _("SQL Expression"), 'table': _("Table"), } + + def post_add(self, new_item): + utils.init_metrics_perm(caravel, [new_item]) + appbuilder.add_view_no_menu(SqlMetricInlineView) class DruidMetricInlineView(CompactCRUDMixin, CaravelModelView): # noqa datamodel = SQLAInterface(models.DruidMetric) - list_columns = ['metric_name', 'verbose_name', 'metric_type'] + list_columns = ['metric_name', 'verbose_name', 'metric_type', + 'is_restricted'] edit_columns = [ 'metric_name', 'description', 'verbose_name', 'metric_type', 'json', - 'datasource'] + 'datasource', 'is_restricted'] add_columns = edit_columns page_size = 500 validators_columns = { @@ -248,6 +260,10 @@ class DruidMetricInlineView(CompactCRUDMixin, CaravelModelView): # noqa "[Druid Post Aggregation]" "(http://druid.io/docs/latest/querying/post-aggregations.html)", True), + 'is_restricted': _("Whether the access to this metric is restricted " + "to certain roles. Only roles with the permission " + "'metric access on XXX (the name of this metric)' " + "are allowed to access this metric"), } label_columns = { 'metric_name': _("Metric"), @@ -257,6 +273,11 @@ class DruidMetricInlineView(CompactCRUDMixin, CaravelModelView): # noqa 'json': _("JSON"), 'datasource': _("Druid Datasource"), } + + def post_add(self, new_item): + utils.init_metrics_perm(caravel, [new_item]) + + appbuilder.add_view_no_menu(DruidMetricInlineView) @@ -819,11 +840,9 @@ def overwrite_slice(self, slc): @expose("/checkbox////", methods=['GET']) def checkbox(self, model_view, id_, attr, value): """endpoint for checking/unchecking any boolean in a sqla model""" - model = None - if model_view == 'TableColumnInlineView': - model = models.TableColumn - elif model_view == 'DruidColumnInlineView': - model = models.DruidColumn + views = sys.modules[__name__] + model_view_cls = getattr(views, model_view) + model = model_view_cls.datamodel.obj obj = db.session.query(model).filter_by(id=id_).first() if obj: diff --git a/docs/security.rst b/docs/security.rst index 0dfc73626fceb..c034758118496 100644 --- a/docs/security.rst +++ b/docs/security.rst @@ -68,3 +68,25 @@ you to create your own roles, and union them to existing ones. The best way to go is probably to give user ``Gamma`` plus another role that would add specific permissions needed by this type of users. + + +Restricting the access to the metrics +------------------------------------- +Sometimes some metrics are relatively sensitive (e.g. revenue). +We may want to restrict those metrics to only a few roles. +For example, assumed there is a metric ``[cluster1].[datasource1].[revenue]`` +and only Admin users are allowed to see it. Here’s how to restrict the access. + +1. Edit the datasource (``Menu -> Source -> Druid datasources -> edit the + record "datasource1"``) and go to the tab ``List Druid Metric``. Check + the checkbox ``Is Restricted`` in the row of the metric ``revenue``. + +2. Edit the role (``Menu -> Security -> List Roles -> edit the record + “Admin”``), in the permissions field, type-and-search the permission + ``metric access on [cluster1].[datasource1].[revenue] (id: 1)``, then + click the Save button on the bottom of the page. + +Any users without the permission will see the error message +*Access to the metrics denied: revenue (Status: 500)* in the slices. +It also happens when the user wants to access a post-aggregation metric that +is dependent on revenue.