From 892cba8366f3a6b66675a361633450b07449a67c Mon Sep 17 00:00:00 2001 From: Cristhian Garcia Date: Thu, 29 Jun 2023 08:57:30 -0500 Subject: [PATCH] feat: add support for extra rlsf --- README.rst | 80 +++++++++++++++++++ .../patches/superset-row-level-security | 8 ++ .../pythonpath/create_row_level_security.py | 38 +++++---- 3 files changed, 106 insertions(+), 20 deletions(-) create mode 100644 tutoraspects/patches/superset-row-level-security diff --git a/README.rst b/README.rst index debd5b24f..3fb5b8c66 100644 --- a/README.rst +++ b/README.rst @@ -209,6 +209,86 @@ Available languages are stored in a mapping, and so best edited directly in Tuto Where the first key is the abbreviation of the language to use, "flag" is which flag icon is displayed in the user interface for choosing the language, and "name" is the displayed name for that language. The mapping above shows all of the current languages supported by Superset, but please note that different languages have different levels of completion and support at this time. +Adding custom Row Level Security Filters to Superset +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +If you add new datasources, tables, or fields to Superset, you may want to add new `row level security filters`_ +to restrict access to that data based on things like course roles, or organization. To apply custom row level +security filters to Superset, you can do so by using the patch `superset-row-level-security`. This patch expects +a list of python dictionaries with the following structure: + +.. code-block:: yaml + + superset-row-level-security: | + { + "schema": "{{ASPECTS_XAPI_DATABASE}}", + "table_name": "{{ASPECTS_XAPI_TABLE}}", + "role_name": "{{SUPERSET_OPENEDX_ROLE_NAME}}", + "group_key": "{{SUPERSET_ROW_LEVEL_SECURITY_XAPI_GROUP_KEY}}", + "clause": {% raw %}'{{can_view_courses(current_username(), "splitByChar(\'/\', course_id)[-1]")}}',{% endraw %} + "filter_type": "Regular", + }, + + +.. _row level security filters: https://superset.apache.org/docs/security#row-level-security + +.. note:: + Make sure that your table already exists before trying to apply a security filter. + If you see an error `AssertionError: {schema.table} table doesn't exist yet?`, then + you need to create the dataset in Superset first. + +You can also add extra SQL `jinja filters`_ to the Superset environment by using the patch +`superset-jinja-filters`, which you can use to define new filters like the ``can_view_courses`` +clause used above. This patch expects valid python code, and the function should return +an SQL fragment as a string, e.g: + +.. _jinja filters: https://superset.apache.org/docs/installation/sql-templating/ + +.. code-block:: yaml + + superset-jinja-filters: | + ALL_COURSES = "1 = 1" + NO_COURSES = "1 = 0" + def can_view_courses(username, field_name="course_id"): + """ + Returns SQL WHERE clause which restricts access to the courses the current user has staff access to. + """ + from superset.extensions import security_manager + user = security_manager.get_user_by_username(username) + if user: + user_roles = security_manager.get_user_roles(user) + else: + user_roles = [] + + # Users with no roles don't get to see any courses + if not user_roles: + return NO_COURSES + + # Superusers and global staff have access to all courses + for role in user_roles: + if str(role) == "Admin" or str(role) == "Alpha": + return ALL_COURSES + + # Everyone else only has access if they're staff on a course. + courses = security_manager.get_courses(username) + + # TODO: what happens when the list of courses grows beyond what the query will handle? + if courses: + course_id_list = ", ".join(f"'{course_id}'" for course_id in courses) + return f"{field_name} in ({course_id_list})" + else: + # If you're not course staff on any courses, you don't get to see any. + return NO_COURSES + +Once the custom jinja filter is necessary to register it using `SUPERSET_EXTRA_JINJA_FILTERS` in the config.yaml +file. It's a dictionary that expects a key for the name of the filter and the name of underlying function: + +.. code-block:: yaml + + SUPERSET_EXTRA_JINJA_FILTERS: + can_view_courses: 'can_view_courses' + + Extending the DBT project ^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/tutoraspects/patches/superset-row-level-security b/tutoraspects/patches/superset-row-level-security new file mode 100644 index 000000000..458284d05 --- /dev/null +++ b/tutoraspects/patches/superset-row-level-security @@ -0,0 +1,8 @@ +{ + "schema": "{{ASPECTS_XAPI_DATABASE}}", + "table_name": "{{ASPECTS_XAPI_TABLE}}", + "role_name": "{{SUPERSET_OPENEDX_ROLE_NAME}}", + "group_key": "{{SUPERSET_ROW_LEVEL_SECURITY_XAPI_GROUP_KEY}}", + "clause": {% raw %}'{{can_view_courses(current_username(), "splitByChar(\'/\', course_id)[-1]")}}',{% endraw %} + "filter_type": "Regular", +}, diff --git a/tutoraspects/templates/aspects/apps/superset/pythonpath/create_row_level_security.py b/tutoraspects/templates/aspects/apps/superset/pythonpath/create_row_level_security.py index 406940d7b..84c01320d 100644 --- a/tutoraspects/templates/aspects/apps/superset/pythonpath/create_row_level_security.py +++ b/tutoraspects/templates/aspects/apps/superset/pythonpath/create_row_level_security.py @@ -7,42 +7,39 @@ RowLevelSecurityFilter, SqlaTable) from superset.extensions import security_manager from superset.migrations.shared.security_converge import Role -from superset.utils.core import RowLevelSecurityFilterType session = security_manager.get_session() -# Fetch the Open edX role -role_name = "{{SUPERSET_OPENEDX_ROLE_NAME}}" -openedx_role = session.query(Role).filter(Role.name == role_name).first() -assert openedx_role, "{{SUPERSET_OPENEDX_ROLE_NAME}} role doesn't exist yet?" +## https://docs.preset.io/docs/row-level-security-rls -for (schema, table_name, group_key, clause, filter_type) in ( - ( - "{{ASPECTS_XAPI_DATABASE}}", - "{{ASPECTS_XAPI_TABLE}}", - "{{SUPERSET_ROW_LEVEL_SECURITY_XAPI_GROUP_KEY}}", - {% raw %} - '{{can_view_courses(current_username(), "splitByChar(\'/\', course_id)[-1]")}}', - {% endraw %} - RowLevelSecurityFilterType.REGULAR, - ), -): +SECURITY_FILTERS = [ + {{ patch('superset-row-level-security')|indent(4) }} +] + + +for security_filter in SECURITY_FILTERS: # Fetch the table we want to restrict access to + schema, table_name, role_name, group_key, clause, filter_type = security_filter.values() table = session.query(SqlaTable).filter( SqlaTable.schema == schema ).filter( SqlaTable.table_name == table_name ).first() - print(table) + assert table, f"{schema}.{table_name} table doesn't exist yet?" + + role = session.query(Role).filter(Role.name == role_name).first() + assert role, f"{role_name} role doesn't exist yet?" # See if the Row Level Security Filter already exists rlsf = ( session.query( RowLevelSecurityFilter ).filter( - RLSFilterRoles.c.role_id.in_((openedx_role.id,)) + RLSFilterRoles.c.role_id.in_((role.id,)) ).filter( RowLevelSecurityFilter.group_key == group_key + ).filter( + RowLevelSecurityFilter.tables.any(id=table.id) ) ).first() # If it doesn't already exist, create one @@ -66,15 +63,16 @@ session.query( RLSFilterRoles ).filter( - RLSFilterRoles.c.role_id == openedx_role.id + RLSFilterRoles.c.role_id == role.id ).filter( RLSFilterRoles.c.rls_filter_id == rlsf.id ) ) + if not rls_filter_roles.count(): session.execute(RLSFilterRoles.insert(), [ dict( - role_id=openedx_role.id, + role_id=role.id, rls_filter_id=rlsf.id ) ])