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

[SIP-29] Add support for row-level security #8699

Merged
merged 49 commits into from
Feb 22, 2020
Merged
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
ab6ae45
Support and apply filters.
altef Nov 27, 2019
024912e
Added the UI for row level security, and moved it all under SQLA in o…
altef Nov 29, 2019
4d6789f
Added a row level security filter documentation entry.
altef Nov 30, 2019
4152dc2
Accidentally added two new lines to this file.
altef Nov 30, 2019
13f1381
Blacked and iSorted, hopefully. Also, sometimes g.user may not be set.
altef Nov 30, 2019
1100a60
Another isort, and handling g not having a user attribute another way.
altef Nov 30, 2019
76a314a
Let's try this again #CI tests.
altef Nov 30, 2019
86234fa
Adjusted import order for isort; I was sure I'd already done this..
altef Nov 30, 2019
81c2bdb
Row level filters should be wrapped in parentheses in case one contai…
altef Nov 30, 2019
ff80add
Oops, did not think that would change Black's formatting.
altef Nov 30, 2019
aafce2f
Changes as per @mistercrunch.
altef Dec 4, 2019
fd0eaf6
RLS filters are now many-to-many with Roles.
altef Dec 4, 2019
8b6ce72
Updated documentation to reflect RLS filters supporting multiple rows.
altef Dec 4, 2019
5f69386
Let's see what happens when I set it to the previous revision ID
altef Dec 4, 2019
032efcb
Merge branch 'master' into row-level-security
altef Dec 4, 2019
2c252d9
Updated from upstream.
altef Dec 4, 2019
7223da6
There was a pylint error.
altef Dec 4, 2019
84a5009
Added RLS ids to the cache keys; modified documentation; added templa…
altef Dec 9, 2019
7e0e3c8
Merge branch 'master' into row-level-security
altef Dec 9, 2019
253e992
A new migration was merged in.
altef Dec 9, 2019
1b3b8f6
Removed RLS cache key from query_object.
altef Dec 9, 2019
c0ac8e9
RLS added to the cache_key from query_context.
altef Dec 10, 2019
bdfaff6
Merge branch 'master' into row-level-security
altef Dec 11, 2019
041038e
Changes as per @etr2460.
altef Dec 17, 2019
8f0d0d1
Updating entry for RLS pull request.
altef Dec 17, 2019
b402fc2
Merge branch 'master' into row-level-security
altef Dec 17, 2019
e403e9b
Another migration to skip.
altef Dec 17, 2019
9c71f05
Catchup.
altef Jan 10, 2020
78ebc1c
Merge branch 'row-level-security' of https://github.com/altef/incubat…
altef Jan 10, 2020
5a0257c
Changes as per @serenajiang.
altef Jan 10, 2020
9d4bcad
Blacked.
altef Jan 10, 2020
790f395
Blacked and added some attributes to check for.
altef Jan 10, 2020
8adfc89
Changed to a manual query as per @mistercrunch.
altef Jan 14, 2020
2b0ccfc
Blacked.
altef Jan 14, 2020
c68d6e6
Merge branch 'master' into row-level-security
altef Jan 14, 2020
b82acf6
Another migration in the meantime.
altef Jan 14, 2020
d94e6aa
Black wanted some whitespace changes.
altef Jan 14, 2020
f76e953
AttributeError: 'AnonymousUserMixin' object has no attribute 'id'.
altef Jan 14, 2020
3d0822d
Oops, did hasattr backwards.
altef Jan 14, 2020
db9f20a
Merge branch 'master' into row-level-security
altef Jan 23, 2020
524c109
Merge branch 'master' into row-level-security
altef Jan 24, 2020
a2e6b67
Changes as per @mistercrunch.
altef Jan 24, 2020
f581cf7
Doesn't look like text us required here anymore.
altef Jan 24, 2020
085a501
Changes as per @dpgaspar
altef Feb 13, 2020
a625111
Two RLS tests.
altef Feb 13, 2020
a131a72
Row level security is now disabled by default via the feature flag EN…
altef Feb 21, 2020
2ea460b
Merge branch 'master' into row-level-security
altef Feb 21, 2020
1451c05
New head to revise.
altef Feb 21, 2020
5eeb296
Changed the comment.
altef Feb 22, 2020
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: 2 additions & 0 deletions UPDATING.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ assists people when migrating to a new version.

## Next

* [8699](https://github.com/apache/incubator-superset/pull/8699): A `row_level_security_filters` table has been added, which is many-to-many with `tables` and `ab_roles`. The applicable filters are added to the sqla query, and the RLS ids are added to the query cache keys. They can be accessed through the `Security` menu, or when editting a table.

* [8732](https://github.com/apache/incubator-superset/pull/8732): Swagger user interface is now enabled by default.
A new permission `show on SwaggerView` is created by `superset init` and given to the `Admin` Role. To disable the UI,
set `FAB_API_SWAGGER_UI = False` on config.
Expand Down
23 changes: 23 additions & 0 deletions docs/security.rst
Original file line number Diff line number Diff line change
Expand Up @@ -153,3 +153,26 @@ a set of data sources that power dashboards only made available to executives.
When looking at its dashboard list, this user will only see the
list of dashboards it has access to, based on the roles and
permissions that were attributed.


Restricting access to a subset of a particular table
""""""""""""""""""""""""""""""""""""""""""""""""""""

Using ``Row level security filters`` (under the ``Security`` menu) you can create
filters that are assigned to a particular table, as well as a set of roles.
Say people in your finance department should only have access to rows where
``department = "finance"``. You could create a ``Row level security filter``
with that clause, and assign it to your ``Finance`` role, as well as the
applicable table.

The ``clause`` field can contain arbitrary text which is then added to the generated
SQL statement's ``WHERE`` clause. So you could even do something like create a
filter for the last 30 days and apply it to a specific role, with a clause like
``date_field > DATE_SUB(NOW(), INTERVAL 30 DAY)``. It can also support multiple
conditions: ``client_id = 6 AND advertiser="foo"``, etc.

All relevant ``Row level security filters`` will be ANDed together, so it's
possible to create a situation where two roles conflict in such a way as to
limit a table subset to empty. For example, the filters ``client_id=4`` and
and ``client_id=5``, applied to a role, will result in users of that role having
``client_id=4 AND client_id=5`` added to their query, which can never be true.
3 changes: 2 additions & 1 deletion superset/common/query_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
import numpy as np
import pandas as pd

from superset import app, cache, db
from superset import app, cache, db, security_manager
from superset.connectors.base.models import BaseDatasource
from superset.connectors.connector_registry import ConnectorRegistry
from superset.stats_logger import BaseStatsLogger
Expand Down Expand Up @@ -169,6 +169,7 @@ def get_df_payload( # pylint: disable=too-many-locals,too-many-statements
query_obj.cache_key(
datasource=self.datasource.uid,
extra_cache_keys=extra_cache_keys,
rls=security_manager.get_rls_ids(self.datasource),
**kwargs
)
if query_obj
Expand Down
45 changes: 43 additions & 2 deletions superset/connectors/sqla/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@
from superset.jinja_context import get_template_processor
from superset.models.annotations import Annotation
from superset.models.core import Database
from superset.models.helpers import QueryResult
from superset.models.helpers import AuditMixinNullable, QueryResult
from superset.utils import core as utils, import_datasource

config = app.config
Expand Down Expand Up @@ -656,6 +656,19 @@ def adhoc_metric_to_sqla(self, metric: Dict, cols: Dict) -> Optional[Column]:

return self.make_sqla_column_compatible(sqla_metric, label)

def _get_sqla_row_level_filters(self, template_processor) -> List[str]:
"""
Return the appropriate row level security filters for this table and the current user.

:param BaseTemplateProcessor template_processor: The template processor to apply to the filters.
:returns: A list of SQL clauses to be ANDed together.
:rtype: List[str]
"""
return [
text("({})".format(template_processor.process_template(f)))
for f in security_manager.get_rls_filters(self)
]

def get_sqla_query( # sqla
self,
groupby,
Expand Down Expand Up @@ -834,6 +847,8 @@ def get_sqla_query( # sqla
where_clause_and.append(col_obj.get_sqla_col() == None)
elif op == "IS NOT NULL":
where_clause_and.append(col_obj.get_sqla_col() != None)

where_clause_and += self._get_sqla_row_level_filters(template_processor)
if extras:
where = extras.get("where")
if where:
Expand Down Expand Up @@ -943,7 +958,6 @@ def get_sqla_query( # sqla
result.df, dimensions, groupby_exprs_sans_timestamp
)
qry = qry.where(top_groups)

return SqlaQuery(
extra_cache_keys=extra_cache_keys,
labels_expected=labels_expected,
Expand Down Expand Up @@ -1169,3 +1183,30 @@ def get_extra_cache_keys(self, query_obj: Dict) -> List[Any]:

sa.event.listen(SqlaTable, "after_insert", security_manager.set_perm)
sa.event.listen(SqlaTable, "after_update", security_manager.set_perm)


RLSFilterRoles = Table(
"rls_filter_roles",
metadata,
Column("id", Integer, primary_key=True),
Column("role_id", Integer, ForeignKey("ab_role.id"), nullable=False),
Column("rls_filter_id", Integer, ForeignKey("row_level_security_filters.id")),
)


class RowLevelSecurityFilter(Model, AuditMixinNullable):
"""
Custom where clauses attached to Tables and Roles.
"""

__tablename__ = "row_level_security_filters"
id = Column(Integer, primary_key=True) # pylint: disable=invalid-name
roles = relationship(
security_manager.role_model,
secondary=RLSFilterRoles,
backref="row_level_security_filters",
)

table_id = Column(Integer, ForeignKey("tables.id"), nullable=False)
table = relationship(SqlaTable, backref="row_level_security_filters")
clause = Column(Text, nullable=False)
48 changes: 47 additions & 1 deletion superset/connectors/sqla/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,48 @@ class SqlMetricInlineView(CompactCRUDMixin, SupersetModelView):
appbuilder.add_view_no_menu(SqlMetricInlineView)
mistercrunch marked this conversation as resolved.
Show resolved Hide resolved


class RowLevelSecurityFiltersModelView(SupersetModelView, DeleteMixin):
datamodel = SQLAInterface(models.RowLevelSecurityFilter)

list_title = _("Row level security filter")
show_title = _("Show Row level security filter")
add_title = _("Add Row level security filter")
edit_title = _("Edit Row level security filter")

list_columns = ["table", "roles", "clause", "creator", "modified"]
Copy link
Member

Choose a reason for hiding this comment

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

small improvement: list_columns = ["table.table_name", "roles", "clause", "creator", "modified"]

order_columns = ["modified"]
Copy link
Member

Choose a reason for hiding this comment

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

small improvement: order_columns = ["table.table_name", "clause", "modified"]

edit_columns = ["table", "roles", "clause"]
show_columns = edit_columns
search_columns = ("table", "roles", "clause")
add_columns = edit_columns
base_order = ("changed_on", "desc")
description_columns = {
"table": _("This is the table this filter will be applied to."),
"roles": _("These are the roles this filter will be applied to."),
"clause": _(
"This is the condition that will be added to the WHERE clause. "
"For example, to only return rows for a particular client, you might put in: client_id = 9"
),
}
label_columns = {
"table": _("Table"),
"roles": _("Roles"),
"clause": _("Clause"),
"creator": _("Creator"),
"modified": _("Modified"),
}


appbuilder.add_view(
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 call this in superset/app.py in the init_views method

Copy link
Contributor Author

Choose a reason for hiding this comment

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

moved

RowLevelSecurityFiltersModelView,
"Row Level Security Filters",
label=__("Row level security filters"),
category="Security",
category_label=__("Security"),
icon="fa-lock",
)


class TableModelView(DatasourceModelView, DeleteMixin, YamlExportMixin):
datamodel = SQLAInterface(models.SqlaTable)

Expand Down Expand Up @@ -256,7 +298,11 @@ class TableModelView(DatasourceModelView, DeleteMixin, YamlExportMixin):
]
base_filters = [["id", DatasourceFilter, lambda: []]]
show_columns = edit_columns + ["perm", "slices"]
related_views = [TableColumnInlineView, SqlMetricInlineView]
related_views = [
TableColumnInlineView,
SqlMetricInlineView,
RowLevelSecurityFiltersModelView,
]
base_order = ("changed_on", "desc")
search_columns = ("database", "schema", "table_name", "owners", "is_sqllab_view")
description_columns = {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""add_role_level_security

Revision ID: 0a6f12f60c73
Revises: 817e1c9b09d0
Create Date: 2019-12-04 17:07:54.390805

"""

# revision identifiers, used by Alembic.
revision = "0a6f12f60c73"
down_revision = "817e1c9b09d0"

import sqlalchemy as sa
from alembic import op


def upgrade():
op.create_table(
"row_level_security_filters",
sa.Column("created_on", sa.DateTime(), nullable=True),
altef marked this conversation as resolved.
Show resolved Hide resolved
sa.Column("changed_on", sa.DateTime(), nullable=True),
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("table_id", sa.Integer(), nullable=False),
sa.Column("clause", sa.Text(), nullable=False),
sa.Column("created_by_fk", sa.Integer(), nullable=True),
sa.Column("changed_by_fk", sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(["changed_by_fk"], ["ab_user.id"]),
sa.ForeignKeyConstraint(["created_by_fk"], ["ab_user.id"]),
sa.ForeignKeyConstraint(["table_id"], ["tables.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"rls_filter_roles",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("role_id", sa.Integer(), nullable=False),
sa.Column("rls_filter_id", sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(["rls_filter_id"], ["row_level_security_filters.id"]),
sa.ForeignKeyConstraint(["role_id"], ["ab_role.id"]),
sa.PrimaryKeyConstraint("id"),
)


def downgrade():
op.drop_table("rls_filter_roles")
op.drop_table("row_level_security_filters")
37 changes: 37 additions & 0 deletions superset/security/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ class SupersetSecurityManager(SecurityManager):
"ResetPasswordView",
"RoleModelView",
"Security",
"RowLevelSecurityFiltersModelView",
} | USER_MODEL_VIEWS

ALPHA_ONLY_VIEW_MENUS = {"Upload a CSV"}
Expand Down Expand Up @@ -877,3 +878,39 @@ def assert_viz_permission(self, viz: "BaseViz") -> None:
"""

self.assert_datasource_permission(viz.datasource)

def get_rls_filters(self, table):
"""
Retrieves the appropriate row level security filters for the current user and the passed table.

:param table: The table to check against
:returns: A list of clause strings.
"""
try:
roles = [role.id for role in g.user.roles]
Copy link
Contributor

Choose a reason for hiding this comment

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

Lots of the same code as get_rls_filters - maybe stick this in a generalized method?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

but.. This is get_rls_filters, isn't it?

Copy link
Contributor

Choose a reason for hiding this comment

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

My bad - meant get_rls_ids XD

Copy link
Contributor Author

Choose a reason for hiding this comment

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

generalized

return [
f.clause
for f in table.row_level_security_filters
Copy link
Member

Choose a reason for hiding this comment

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

Given that by default sqlalchemy lazy-loads relationships, this would emit 1+number_of_rls_in_table queries.

Let's write a single sqlalchemy query that returns the list of filters with the proper IN clause

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done - this reduces the number of necessary hasattr calls as well.

if any(r.id in roles for r in f.roles)
]
except AttributeError:
return []

def get_rls_ids(self, table) -> List[int]:
mistercrunch marked this conversation as resolved.
Show resolved Hide resolved
"""
Retrieves the appropriate row level security filters IDs for the current user and the passed table.

:param table: The table to check against
:returns: A list of IDs.
"""
try:
roles = [role.id for role in g.user.roles]
ids = [
f.id
for f in table.row_level_security_filters
if any(r.id in roles for r in f.roles)
]
ids.sort() # Combinations rather than permutations
return ids
except AttributeError:
Copy link
Contributor

Choose a reason for hiding this comment

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

Hmm, I know it's a bit more verbose, but I prefer checking for the existence of the attribute using hasattr rather than catching the AttributeError.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Isn't try/except more "pythonic"? I have no problem changing this, but worry about someone else then requesting it be changed back. Can we get a second opinion on this one?

Copy link
Member

Choose a reason for hiding this comment

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

I agree with @serenajiang here; best to be explicit about the attribute we`re after.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

done

return []
3 changes: 2 additions & 1 deletion superset/viz.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
from markdown import markdown
from pandas.tseries.frequencies import to_offset

from superset import app, cache, get_css_manifest_files
from superset import app, cache, get_css_manifest_files, security_manager
from superset.constants import NULL_STRING
from superset.exceptions import NullValueException, SpatialException
from superset.utils import core as utils
Expand Down Expand Up @@ -367,6 +367,7 @@ def cache_key(self, query_obj, **extra):
cache_dict["time_range"] = self.form_data.get("time_range")
cache_dict["datasource"] = self.datasource.uid
cache_dict["extra_cache_keys"] = self.datasource.get_extra_cache_keys(query_obj)
cache_dict["rls"] = security_manager.get_rls_ids(self.datasource)
json_data = self.json_dumps(cache_dict, sort_keys=True)
return hashlib.md5(json_data.encode("utf-8")).hexdigest()

Expand Down