mirror of
https://github.com/apache/superset.git
synced 2026-04-26 19:44:58 +00:00
feat: embedded dashboard core (#17530)
* feat(dashboard): embedded dashboard UI configuration (#17175) (#17450) * setup embedded provider * update ui configuration * fix test * feat: Guest token (for embedded dashboard auth) (#17517) * generate an embed token * improve existing tests * add some auth setup, and rename token * fix the stuff for compatibility with external request loaders * docs, standard jwt claims, tweaks * black * lint * tests, and safer token decoding * linting * type annotation * prettier * add feature flag * quiet pylint * apparently typing is a problem again * Make guest role name configurable * fake being a non-anonymous user * just one log entry * customizable algo * lint * lint again * 403 works now! * get guest token from header instead of cookie * Revert "403 works now!" This reverts commitdf2f49a6d4. * fix tests * Revert "Revert "403 works now!"" This reverts commit883dff38f1. * rename method * correct import * feat: entry for embedded dashboard (#17529) * create entry for embedded dashboard in webpack * add cookies * lint * token message handshake * guestTokenHeaderName * use setupClient instead of calling configure * rename the webpack chunk * simplified handshake * embedded entrypoint: render a proper app * make the embedded page accept anonymous connections * format * lint * fix test # Conflicts: # superset-frontend/src/embedded/index.tsx # superset/views/core.py * lint * Update superset-frontend/src/embedded/index.tsx Co-authored-by: David Aaron Suddjian <1858430+suddjian@users.noreply.github.com> * comment out origins checks * move embedded for core to dashboard * pylint * isort Co-authored-by: David Aaron Suddjian <aasuddjian@gmail.com> Co-authored-by: David Aaron Suddjian <1858430+suddjian@users.noreply.github.com> * feat: Authorizing guest access to embedded dashboards (#17757) * helper methods and dashboard access * guest token dashboard authz * adjust csrf exempt list * eums don't work that way * Remove unnecessary import * move row level security tests to their own file * a bit of refactoring * add guest token security tests * refactor tests * clean imports * variable names can be too long apparently * missing argument to get_user_roles * don't redefine builtins * remove unused imports * fix test import * default to global user when getting roles * missing import * mock it * test get_user_roles * infer g.user for ease of tests * remove redundant check * tests for guest user security manager fns * use algo to get rid of warning messages * tweaking access checks * fix guest token security tests * missing imports * more tests * more testing and also some small refactoring * move validation out of parsing * fix dashboard access check again * add more test Co-authored-by: Lily Kuang <lily@preset.io> * feat: Row Level Security rules for guest tokens (#17836) * helper methods and dashboard access * guest token dashboard authz * adjust csrf exempt list * eums don't work that way * Remove unnecessary import * move row level security tests to their own file * a bit of refactoring * add guest token security tests * refactor tests * clean imports * variable names can be too long apparently * missing argument to get_user_roles * don't redefine builtins * remove unused imports * fix test import * default to global user when getting roles * missing import * mock it * test get_user_roles * infer g.user for ease of tests * remove redundant check * tests for guest user security manager fns * use algo to get rid of warning messages * tweaking access checks * fix guest token security tests * missing imports * more tests * more testing and also some small refactoring * move validation out of parsing * fix dashboard access check again * rls rules for guest tokens * test guest token rls rules * more flexible rls rules * lint * fix tests * fix test * defaults * fix some tests * fix some tests * lint Co-authored-by: Lily Kuang <lily@preset.io> * SupersetClient guest token test * Apply suggestions from code review Co-authored-by: Lily Kuang <lily@preset.io> Co-authored-by: Lily Kuang <lily@preset.io>
This commit is contained in:
committed by
GitHub
parent
62009773a6
commit
4ad5ad045a
@@ -16,23 +16,24 @@
|
||||
# under the License.
|
||||
# isort:skip_file
|
||||
import inspect
|
||||
import re
|
||||
import time
|
||||
import unittest
|
||||
from collections import namedtuple
|
||||
from unittest import mock
|
||||
from unittest.mock import Mock, patch
|
||||
from typing import Any, Dict
|
||||
from typing import Any
|
||||
|
||||
import jwt
|
||||
import prison
|
||||
import pytest
|
||||
|
||||
from flask import current_app, g
|
||||
from flask import current_app
|
||||
|
||||
from superset.models.dashboard import Dashboard
|
||||
|
||||
from superset import app, appbuilder, db, security_manager, viz, ConnectorRegistry
|
||||
from superset.connectors.druid.models import DruidCluster, DruidDatasource
|
||||
from superset.connectors.sqla.models import RowLevelSecurityFilter, SqlaTable
|
||||
from superset.connectors.sqla.models import SqlaTable
|
||||
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
from superset.models.core import Database
|
||||
@@ -46,22 +47,10 @@ from superset.utils.database import get_example_database
|
||||
from superset.views.access_requests import AccessRequestsModelView
|
||||
|
||||
from .base_tests import SupersetTestCase
|
||||
from tests.integration_tests.fixtures.birth_names_dashboard import (
|
||||
load_birth_names_dashboard_with_slices,
|
||||
load_birth_names_data,
|
||||
)
|
||||
from tests.integration_tests.fixtures.energy_dashboard import (
|
||||
load_energy_table_with_slice,
|
||||
load_energy_table_data,
|
||||
)
|
||||
from tests.integration_tests.fixtures.public_role import (
|
||||
public_role_like_gamma,
|
||||
public_role_like_test_role,
|
||||
)
|
||||
from tests.integration_tests.fixtures.unicode_dashboard import (
|
||||
load_unicode_dashboard_with_slice,
|
||||
load_unicode_data,
|
||||
)
|
||||
from tests.integration_tests.fixtures.world_bank_dashboard import (
|
||||
load_world_bank_dashboard_with_slices,
|
||||
load_world_bank_data,
|
||||
@@ -917,6 +906,7 @@ class TestRolePermission(SupersetTestCase):
|
||||
["LocaleView", "index"],
|
||||
["AuthDBView", "login"],
|
||||
["AuthDBView", "logout"],
|
||||
["Dashboard", "embedded"],
|
||||
["R", "index"],
|
||||
["Superset", "log"],
|
||||
["Superset", "theme"],
|
||||
@@ -975,9 +965,7 @@ class TestSecurityManager(SupersetTestCase):
|
||||
|
||||
mock_raise_for_access.side_effect = SupersetSecurityException(
|
||||
SupersetError(
|
||||
"dummy",
|
||||
SupersetErrorType.TABLE_SECURITY_ACCESS_ERROR,
|
||||
ErrorLevel.ERROR,
|
||||
"dummy", SupersetErrorType.TABLE_SECURITY_ACCESS_ERROR, ErrorLevel.ERROR
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1054,174 +1042,18 @@ class TestSecurityManager(SupersetTestCase):
|
||||
with self.assertRaises(SupersetSecurityException):
|
||||
security_manager.raise_for_access(viz=test_viz)
|
||||
|
||||
@patch("superset.security.manager.g")
|
||||
def test_get_user_roles(self, mock_g):
|
||||
admin = security_manager.find_user("admin")
|
||||
mock_g.user = admin
|
||||
roles = security_manager.get_user_roles()
|
||||
self.assertEqual(admin.roles, roles)
|
||||
|
||||
class TestRowLevelSecurity(SupersetTestCase):
|
||||
"""
|
||||
Testing Row Level Security
|
||||
"""
|
||||
|
||||
rls_entry = None
|
||||
query_obj: Dict[str, Any] = dict(
|
||||
groupby=[],
|
||||
metrics=None,
|
||||
filter=[],
|
||||
is_timeseries=False,
|
||||
columns=["value"],
|
||||
granularity=None,
|
||||
from_dttm=None,
|
||||
to_dttm=None,
|
||||
extras={},
|
||||
)
|
||||
NAME_AB_ROLE = "NameAB"
|
||||
NAME_Q_ROLE = "NameQ"
|
||||
NAMES_A_REGEX = re.compile(r"name like 'A%'")
|
||||
NAMES_B_REGEX = re.compile(r"name like 'B%'")
|
||||
NAMES_Q_REGEX = re.compile(r"name like 'Q%'")
|
||||
BASE_FILTER_REGEX = re.compile(r"gender = 'boy'")
|
||||
|
||||
def setUp(self):
|
||||
session = db.session
|
||||
|
||||
# Create roles
|
||||
security_manager.add_role(self.NAME_AB_ROLE)
|
||||
security_manager.add_role(self.NAME_Q_ROLE)
|
||||
gamma_user = security_manager.find_user(username="gamma")
|
||||
gamma_user.roles.append(security_manager.find_role(self.NAME_AB_ROLE))
|
||||
gamma_user.roles.append(security_manager.find_role(self.NAME_Q_ROLE))
|
||||
self.create_user_with_roles("NoRlsRoleUser", ["Gamma"])
|
||||
session.commit()
|
||||
|
||||
# Create regular RowLevelSecurityFilter (energy_usage, unicode_test)
|
||||
self.rls_entry1 = RowLevelSecurityFilter()
|
||||
self.rls_entry1.tables.extend(
|
||||
session.query(SqlaTable)
|
||||
.filter(SqlaTable.table_name.in_(["energy_usage", "unicode_test"]))
|
||||
.all()
|
||||
)
|
||||
self.rls_entry1.filter_type = "Regular"
|
||||
self.rls_entry1.clause = "value > {{ cache_key_wrapper(1) }}"
|
||||
self.rls_entry1.group_key = None
|
||||
self.rls_entry1.roles.append(security_manager.find_role("Gamma"))
|
||||
self.rls_entry1.roles.append(security_manager.find_role("Alpha"))
|
||||
db.session.add(self.rls_entry1)
|
||||
|
||||
# Create regular RowLevelSecurityFilter (birth_names name starts with A or B)
|
||||
self.rls_entry2 = RowLevelSecurityFilter()
|
||||
self.rls_entry2.tables.extend(
|
||||
session.query(SqlaTable)
|
||||
.filter(SqlaTable.table_name.in_(["birth_names"]))
|
||||
.all()
|
||||
)
|
||||
self.rls_entry2.filter_type = "Regular"
|
||||
self.rls_entry2.clause = "name like 'A%' or name like 'B%'"
|
||||
self.rls_entry2.group_key = "name"
|
||||
self.rls_entry2.roles.append(security_manager.find_role("NameAB"))
|
||||
db.session.add(self.rls_entry2)
|
||||
|
||||
# Create Regular RowLevelSecurityFilter (birth_names name starts with Q)
|
||||
self.rls_entry3 = RowLevelSecurityFilter()
|
||||
self.rls_entry3.tables.extend(
|
||||
session.query(SqlaTable)
|
||||
.filter(SqlaTable.table_name.in_(["birth_names"]))
|
||||
.all()
|
||||
)
|
||||
self.rls_entry3.filter_type = "Regular"
|
||||
self.rls_entry3.clause = "name like 'Q%'"
|
||||
self.rls_entry3.group_key = "name"
|
||||
self.rls_entry3.roles.append(security_manager.find_role("NameQ"))
|
||||
db.session.add(self.rls_entry3)
|
||||
|
||||
# Create Base RowLevelSecurityFilter (birth_names boys)
|
||||
self.rls_entry4 = RowLevelSecurityFilter()
|
||||
self.rls_entry4.tables.extend(
|
||||
session.query(SqlaTable)
|
||||
.filter(SqlaTable.table_name.in_(["birth_names"]))
|
||||
.all()
|
||||
)
|
||||
self.rls_entry4.filter_type = "Base"
|
||||
self.rls_entry4.clause = "gender = 'boy'"
|
||||
self.rls_entry4.group_key = "gender"
|
||||
self.rls_entry4.roles.append(security_manager.find_role("Admin"))
|
||||
db.session.add(self.rls_entry4)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
def tearDown(self):
|
||||
session = db.session
|
||||
session.delete(self.rls_entry1)
|
||||
session.delete(self.rls_entry2)
|
||||
session.delete(self.rls_entry3)
|
||||
session.delete(self.rls_entry4)
|
||||
session.delete(security_manager.find_role("NameAB"))
|
||||
session.delete(security_manager.find_role("NameQ"))
|
||||
session.delete(self.get_user("NoRlsRoleUser"))
|
||||
session.commit()
|
||||
|
||||
@pytest.mark.usefixtures("load_energy_table_with_slice")
|
||||
def test_rls_filter_alters_energy_query(self):
|
||||
g.user = self.get_user(username="alpha")
|
||||
tbl = self.get_table(name="energy_usage")
|
||||
sql = tbl.get_query_str(self.query_obj)
|
||||
assert tbl.get_extra_cache_keys(self.query_obj) == [1]
|
||||
assert "value > 1" in sql
|
||||
|
||||
@pytest.mark.usefixtures("load_energy_table_with_slice")
|
||||
def test_rls_filter_doesnt_alter_energy_query(self):
|
||||
g.user = self.get_user(
|
||||
username="admin"
|
||||
) # self.login() doesn't actually set the user
|
||||
tbl = self.get_table(name="energy_usage")
|
||||
sql = tbl.get_query_str(self.query_obj)
|
||||
assert tbl.get_extra_cache_keys(self.query_obj) == []
|
||||
assert "value > 1" not in sql
|
||||
|
||||
@pytest.mark.usefixtures("load_unicode_dashboard_with_slice")
|
||||
def test_multiple_table_filter_alters_another_tables_query(self):
|
||||
g.user = self.get_user(
|
||||
username="alpha"
|
||||
) # self.login() doesn't actually set the user
|
||||
tbl = self.get_table(name="unicode_test")
|
||||
sql = tbl.get_query_str(self.query_obj)
|
||||
assert tbl.get_extra_cache_keys(self.query_obj) == [1]
|
||||
assert "value > 1" in sql
|
||||
|
||||
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
|
||||
def test_rls_filter_alters_gamma_birth_names_query(self):
|
||||
g.user = self.get_user(username="gamma")
|
||||
tbl = self.get_table(name="birth_names")
|
||||
sql = tbl.get_query_str(self.query_obj)
|
||||
|
||||
# establish that the filters are grouped together correctly with
|
||||
# ANDs, ORs and parens in the correct place
|
||||
assert (
|
||||
"WHERE ((name like 'A%'\n or name like 'B%')\n OR (name like 'Q%'))\n AND (gender = 'boy');"
|
||||
in sql
|
||||
)
|
||||
|
||||
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
|
||||
def test_rls_filter_alters_no_role_user_birth_names_query(self):
|
||||
g.user = self.get_user(username="NoRlsRoleUser")
|
||||
tbl = self.get_table(name="birth_names")
|
||||
sql = tbl.get_query_str(self.query_obj)
|
||||
|
||||
# gamma's filters should not be present query
|
||||
assert not self.NAMES_A_REGEX.search(sql)
|
||||
assert not self.NAMES_B_REGEX.search(sql)
|
||||
assert not self.NAMES_Q_REGEX.search(sql)
|
||||
# base query should be present
|
||||
assert self.BASE_FILTER_REGEX.search(sql)
|
||||
|
||||
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
|
||||
def test_rls_filter_doesnt_alter_admin_birth_names_query(self):
|
||||
g.user = self.get_user(username="admin")
|
||||
tbl = self.get_table(name="birth_names")
|
||||
sql = tbl.get_query_str(self.query_obj)
|
||||
|
||||
# no filters are applied for admin user
|
||||
assert not self.NAMES_A_REGEX.search(sql)
|
||||
assert not self.NAMES_B_REGEX.search(sql)
|
||||
assert not self.NAMES_Q_REGEX.search(sql)
|
||||
assert not self.BASE_FILTER_REGEX.search(sql)
|
||||
@patch("superset.security.manager.g")
|
||||
def test_get_anonymous_roles(self, mock_g):
|
||||
mock_g.user = security_manager.get_anonymous_user()
|
||||
roles = security_manager.get_user_roles()
|
||||
self.assertEqual([security_manager.get_public_role()], roles)
|
||||
|
||||
|
||||
class TestAccessRequestEndpoints(SupersetTestCase):
|
||||
@@ -1323,3 +1155,89 @@ class TestDatasources(SupersetTestCase):
|
||||
Datasource("database1", "schema1", "table1"),
|
||||
Datasource("database1", "schema1", "table2"),
|
||||
]
|
||||
|
||||
|
||||
class FakeRequest:
|
||||
headers: Any = {}
|
||||
|
||||
|
||||
class TestGuestTokens(SupersetTestCase):
|
||||
def create_guest_token(self):
|
||||
user = {"username": "test_guest"}
|
||||
resources = [{"some": "resource"}]
|
||||
rls = [{"dataset": 1, "clause": "access = 1"}]
|
||||
return security_manager.create_guest_access_token(user, resources, rls)
|
||||
|
||||
@patch("superset.security.SupersetSecurityManager._get_current_epoch_time")
|
||||
def test_create_guest_access_token(self, get_time_mock):
|
||||
now = time.time()
|
||||
get_time_mock.return_value = now # so we know what it should =
|
||||
|
||||
user = {"username": "test_guest"}
|
||||
resources = [{"some": "resource"}]
|
||||
rls = [{"dataset": 1, "clause": "access = 1"}]
|
||||
token = security_manager.create_guest_access_token(user, resources, rls)
|
||||
|
||||
# unfortunately we cannot mock time in the jwt lib
|
||||
decoded_token = jwt.decode(
|
||||
token,
|
||||
self.app.config["GUEST_TOKEN_JWT_SECRET"],
|
||||
algorithms=[self.app.config["GUEST_TOKEN_JWT_ALGO"]],
|
||||
)
|
||||
|
||||
self.assertEqual(user, decoded_token["user"])
|
||||
self.assertEqual(resources, decoded_token["resources"])
|
||||
self.assertEqual(now, decoded_token["iat"])
|
||||
self.assertEqual(
|
||||
now + (self.app.config["GUEST_TOKEN_JWT_EXP_SECONDS"] * 1000),
|
||||
decoded_token["exp"],
|
||||
)
|
||||
|
||||
def test_get_guest_user(self):
|
||||
token = self.create_guest_token()
|
||||
fake_request = FakeRequest()
|
||||
fake_request.headers[current_app.config["GUEST_TOKEN_HEADER_NAME"]] = token
|
||||
|
||||
guest_user = security_manager.get_guest_user_from_request(fake_request)
|
||||
|
||||
self.assertIsNotNone(guest_user)
|
||||
self.assertEqual("test_guest", guest_user.username)
|
||||
|
||||
@patch("superset.security.SupersetSecurityManager._get_current_epoch_time")
|
||||
def test_get_guest_user_expired_token(self, get_time_mock):
|
||||
# make a just-expired token
|
||||
get_time_mock.return_value = (
|
||||
time.time() - (self.app.config["GUEST_TOKEN_JWT_EXP_SECONDS"] * 1000) - 1
|
||||
)
|
||||
token = self.create_guest_token()
|
||||
fake_request = FakeRequest()
|
||||
fake_request.headers[current_app.config["GUEST_TOKEN_HEADER_NAME"]] = token
|
||||
|
||||
guest_user = security_manager.get_guest_user_from_request(fake_request)
|
||||
|
||||
self.assertIsNone(guest_user)
|
||||
|
||||
def test_get_guest_user_no_user(self):
|
||||
user = None
|
||||
resources = [{"type": "dashboard", "id": 1}]
|
||||
rls = {}
|
||||
token = security_manager.create_guest_access_token(user, resources, rls)
|
||||
fake_request = FakeRequest()
|
||||
fake_request.headers[current_app.config["GUEST_TOKEN_HEADER_NAME"]] = token
|
||||
guest_user = security_manager.get_guest_user_from_request(fake_request)
|
||||
|
||||
self.assertIsNone(guest_user)
|
||||
self.assertRaisesRegex(ValueError, "Guest token does not contain a user claim")
|
||||
|
||||
def test_get_guest_user_no_resource(self):
|
||||
user = {"username": "test_guest"}
|
||||
resources = []
|
||||
rls = {}
|
||||
token = security_manager.create_guest_access_token(user, resources, rls)
|
||||
fake_request = FakeRequest()
|
||||
fake_request.headers[current_app.config["GUEST_TOKEN_HEADER_NAME"]] = token
|
||||
security_manager.get_guest_user_from_request(fake_request)
|
||||
|
||||
self.assertRaisesRegex(
|
||||
ValueError, "Guest token does not contain a resources claim"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user