Files
superset2/tests/integration_tests/security/row_level_security_tests.py

1288 lines
43 KiB
Python

# 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.
# isort:skip_file
import re
from typing import Any, Optional
from unittest import mock
import pytest
from flask import g
import rison
from superset import db, security_manager
from superset.connectors.sqla.models import RowLevelSecurityFilter, SqlaTable
from superset.subjects.models import Subject
from superset.subjects.types import SubjectType
from superset.security.guest_token import (
GuestTokenResourceType,
GuestUser,
)
from superset.utils import json
from flask_babel import lazy_gettext as _ # noqa: F401
from flask_appbuilder.models.sqla import filters
from tests.integration_tests.base_tests import SupersetTestCase
from tests.integration_tests.constants import ADMIN_USERNAME
from tests.integration_tests.fixtures.birth_names_dashboard import (
load_birth_names_dashboard_with_slices, # noqa: F401
load_birth_names_data, # noqa: F401
)
from tests.integration_tests.fixtures.energy_dashboard import (
load_energy_table_with_slice, # noqa: F401
load_energy_table_data, # noqa: F401
)
from tests.integration_tests.fixtures.unicode_dashboard import (
UNICODE_TBL_NAME, # noqa: F401
load_unicode_dashboard_with_slice, # noqa: F401
load_unicode_data, # noqa: F401
)
from tests.integration_tests.test_app import app, login
# ---------------------------------------------------------------------------
# Module-level constants
# ---------------------------------------------------------------------------
QUERY_OBJ: dict[str, Any] = dict( # noqa: C408
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'")
DEFAULT_PASSWORD = "general" # noqa: S105
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _get_table(
name: str, database_id: Optional[int] = None, schema: Optional[str] = None
) -> SqlaTable:
"""Delegate to SupersetTestCase.get_table (static helper)."""
return SupersetTestCase.get_table(name=name, database_id=database_id, schema=schema)
def _get_user(username: str):
return SupersetTestCase.get_user(username)
def _get_test_dataset() -> Optional[SqlaTable]:
return (
db.session.query(SqlaTable).filter(SqlaTable.table_name == "table1")
).one_or_none()
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def admin_client(app_context):
"""Provide a logged-in admin test client."""
with app.test_client() as client:
login(client, username=ADMIN_USERNAME, password=DEFAULT_PASSWORD)
yield client
@pytest.fixture
def rls_filters(app_context):
"""Create RLS filter test data with proper session management.
Wraps all FAB role / user mutations in ``no_autoflush`` to prevent the
Subject-sync hooks from triggering premature autoflush of unrelated
pending objects (e.g. RLS M2M associations).
"""
# Defensive cleanup of any leftover data from a previous failed run
_cleanup_rls_data()
with db.session.no_autoflush:
# Create roles
role_ab = security_manager.add_role(NAME_AB_ROLE)
role_q = security_manager.add_role(NAME_Q_ROLE)
gamma_user = security_manager.find_user(username="gamma")
gamma_user.roles.append(role_ab)
gamma_user.roles.append(role_q)
SupersetTestCase.create_user_with_roles("NoRlsRoleUser", ["Gamma"])
db.session.commit()
# Helper: look up the ROLE-type Subject for a given role
def _role_subject(role_name: str) -> "Subject":
role = security_manager.find_role(role_name)
return (
db.session.query(Subject)
.filter_by(role_id=role.id, type=SubjectType.ROLE)
.one()
)
with db.session.no_autoflush:
# Create regular RowLevelSecurityFilter (energy_usage, unicode_test)
rls_entry1 = RowLevelSecurityFilter()
rls_entry1.name = "rls_entry1"
rls_entry1.tables.extend(
db.session.query(SqlaTable)
.filter(SqlaTable.table_name.in_(["energy_usage", "unicode_test"]))
.all()
)
rls_entry1.filter_type = "Regular"
rls_entry1.clause = "value > {{ cache_key_wrapper(1) }}"
rls_entry1.group_key = None
rls_entry1.subjects.append(_role_subject("Gamma"))
rls_entry1.subjects.append(_role_subject("Alpha"))
db.session.add(rls_entry1)
# Create regular RowLevelSecurityFilter (birth_names name starts with A or B)
rls_entry2 = RowLevelSecurityFilter()
rls_entry2.name = "rls_entry2"
rls_entry2.tables.extend(
db.session.query(SqlaTable)
.filter(SqlaTable.table_name.in_(["birth_names"]))
.all()
)
rls_entry2.filter_type = "Regular"
rls_entry2.clause = "name like 'A%' or name like 'B%'"
rls_entry2.group_key = "name"
rls_entry2.subjects.append(_role_subject("NameAB"))
db.session.add(rls_entry2)
# Create Regular RowLevelSecurityFilter (birth_names name starts with Q)
rls_entry3 = RowLevelSecurityFilter()
rls_entry3.name = "rls_entry3"
rls_entry3.tables.extend(
db.session.query(SqlaTable)
.filter(SqlaTable.table_name.in_(["birth_names"]))
.all()
)
rls_entry3.filter_type = "Regular"
rls_entry3.clause = "name like 'Q%'"
rls_entry3.group_key = "name"
rls_entry3.subjects.append(_role_subject("NameQ"))
db.session.add(rls_entry3)
# Create Base RowLevelSecurityFilter (birth_names boys)
rls_entry4 = RowLevelSecurityFilter()
rls_entry4.name = "rls_entry4"
rls_entry4.tables.extend(
db.session.query(SqlaTable)
.filter(SqlaTable.table_name.in_(["birth_names"]))
.all()
)
rls_entry4.filter_type = "Base"
rls_entry4.clause = "gender = 'boy'"
rls_entry4.group_key = "gender"
rls_entry4.subjects.append(_role_subject("Admin"))
db.session.add(rls_entry4)
db.session.commit()
yield {
"rls_entry1": rls_entry1,
"rls_entry2": rls_entry2,
"rls_entry3": rls_entry3,
"rls_entry4": rls_entry4,
}
# Cleanup
_cleanup_rls_data()
def _cleanup_rls_data():
"""Remove all RLS test data, tolerating missing/detached objects."""
db.session.rollback()
with db.session.no_autoflush:
for name in ["rls_entry1", "rls_entry2", "rls_entry3", "rls_entry4"]:
if entry := (
db.session.query(RowLevelSecurityFilter).filter_by(name=name).first()
):
db.session.delete(entry)
db.session.flush()
# Remove test roles from gamma before deleting the roles
gamma_user = security_manager.find_user(username="gamma")
if gamma_user:
gamma_user.roles = [
r for r in gamma_user.roles if r.name not in [NAME_AB_ROLE, NAME_Q_ROLE]
]
for role_name in [NAME_AB_ROLE, NAME_Q_ROLE]:
if role := security_manager.find_role(role_name):
db.session.delete(role)
if user := _get_user("NoRlsRoleUser"):
db.session.delete(user)
db.session.commit()
@pytest.fixture
def create_dataset(app_context):
"""Create a temporary dataset for API tests."""
# Clean up leftover from a previous failed run
_cleanup_test_dataset()
dataset = SqlaTable(database_id=1, schema=None, table_name="table1")
db.session.add(dataset)
db.session.flush()
db.session.commit()
yield dataset
_cleanup_test_dataset()
def _cleanup_test_dataset():
"""Remove the test dataset, tolerating missing objects."""
db.session.rollback()
dataset = db.session.query(SqlaTable).filter_by(table_name="table1").first()
if dataset:
db.session.delete(dataset)
db.session.commit()
def _subject_for_role(role: Any) -> Subject:
"""Return the ROLE-type subject for a FAB role."""
from superset.subjects.sync import sync_role_subject
sync_role_subject(role)
db.session.flush()
return (
db.session.query(Subject)
.filter_by(role_id=role.id, type=SubjectType.ROLE)
.one()
)
class TestRowLevelSecurityUpdateAPI(SupersetTestCase):
def test_invalid_id_failure(self):
self.login(ADMIN_USERNAME)
payload = {
"name": "rls 1",
"clause": "1=1",
"filter_type": "Base",
"tables": [1],
"subjects": [1],
}
rv = self.client.put("/api/v1/rowlevelsecurity/99999999", json=payload)
status_code, data = rv.status_code, json.loads(rv.data.decode("utf-8"))
assert status_code == 404
assert data["message"] == "Not found"
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
def test_invalid_subject_failure(self):
table = db.session.query(SqlaTable).first()
rls = RowLevelSecurityFilter(
name="rls test invalid role",
clause="1=1",
filter_type="Regular",
tables=[table],
)
db.session.add(rls)
db.session.commit()
self.login(ADMIN_USERNAME)
payload = {
"subjects": [999999],
}
rv = self.client.put(f"/api/v1/rowlevelsecurity/{rls.id}", json=payload)
status_code, data = rv.status_code, json.loads(rv.data.decode("utf-8"))
assert status_code == 422
assert data["message"] == "[l'Subjects are invalid']"
db.session.delete(rls)
db.session.commit()
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
def test_invalid_table_failure(self):
table = db.session.query(SqlaTable).first()
rls = RowLevelSecurityFilter(
name="rls test invalid role",
clause="1=1",
filter_type="Regular",
tables=[table],
)
db.session.add(rls)
db.session.commit()
self.login(ADMIN_USERNAME)
payload = {
"name": "rls 1",
"clause": "1=1",
"filter_type": "Base",
"tables": [999999],
"subjects": [1],
}
rv = self.client.put(f"/api/v1/rowlevelsecurity/{rls.id}", json=payload)
status_code, data = rv.status_code, json.loads(rv.data.decode("utf-8"))
assert status_code == 422
assert data["message"] == "[l'Datasource does not exist']"
db.session.delete(rls)
db.session.commit()
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
@pytest.mark.usefixtures("load_energy_table_with_slice")
def test_put_success(self):
tables = db.session.query(SqlaTable).limit(2).all()
roles = db.session.query(security_manager.role_model).limit(2).all()
role_subjects = [_subject_for_role(role) for role in roles]
rls = RowLevelSecurityFilter(
name="rls 1",
clause="1=1",
filter_type="Regular",
tables=[tables[0]],
subjects=[role_subjects[0]],
)
db.session.add(rls)
db.session.commit()
self.login(ADMIN_USERNAME)
payload = {
"name": "rls put success",
"clause": "2=2",
"filter_type": "Base",
"tables": [tables[1].id],
"subjects": [role_subjects[1].id],
}
rv = self.client.put(f"/api/v1/rowlevelsecurity/{rls.id}", json=payload)
status_code, _data = rv.status_code, json.loads(rv.data.decode("utf-8")) # noqa: F841
assert status_code == 200
rls = (
db.session.query(RowLevelSecurityFilter)
.filter(RowLevelSecurityFilter.id == rls.id)
.one_or_none()
)
assert rls.name == "rls put success"
assert rls.clause == "2=2"
assert rls.filter_type == "Base"
assert rls.tables[0].id == tables[1].id
assert rls.subjects[0].role_id == roles[1].id
db.session.delete(rls)
db.session.commit()
class TestRowLevelSecurityDeleteAPI(SupersetTestCase):
def test_invalid_id_failure(self):
self.login(ADMIN_USERNAME)
ids_to_delete = rison.dumps([10000, 10001, 100002])
rv = self.client.delete(f"/api/v1/rowlevelsecurity/?q={ids_to_delete}")
status_code, data = rv.status_code, json.loads(rv.data.decode("utf-8"))
assert status_code == 404
assert data["message"] == "Not found"
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
@pytest.mark.usefixtures("load_energy_table_with_slice")
def test_bulk_delete_success(self):
tables = db.session.query(SqlaTable).limit(2).all()
roles = db.session.query(security_manager.role_model).limit(2).all()
role_subjects = [_subject_for_role(role) for role in roles]
rls_1 = RowLevelSecurityFilter(
name="rls 1",
clause="1=1",
filter_type="Regular",
tables=[tables[0]],
subjects=[role_subjects[0]],
)
rls_2 = RowLevelSecurityFilter(
name="rls 2",
clause="2=2",
filter_type="Base",
tables=[tables[1]],
subjects=[role_subjects[1]],
)
db.session.add_all([rls_1, rls_2])
db.session.commit()
self.login(ADMIN_USERNAME)
ids_to_delete = rison.dumps([rls_1.id, rls_2.id])
rv = self.client.delete(f"/api/v1/rowlevelsecurity/?q={ids_to_delete}")
status_code, data = rv.status_code, json.loads(rv.data.decode("utf-8"))
assert status_code == 200
assert data["message"] == "Deleted 2 rules"
class TestRowLevelSecurityWithRelatedAPI(SupersetTestCase):
@pytest.mark.usefixtures("load_birth_names_data")
@pytest.mark.usefixtures("load_energy_table_data")
def test_rls_tables_related_api(self):
self.login(ADMIN_USERNAME)
params = rison.dumps({"page": 0, "page_size": 100})
rv = self.client.get(f"/api/v1/rowlevelsecurity/related/tables?q={params}")
assert rv.status_code == 200
data = json.loads(rv.data.decode("utf-8"))
result = data["result"]
db_tables = db.session.query(SqlaTable).all()
db_table_names = {t.name for t in db_tables}
received_tables = {table["text"] for table in result}
assert data["count"] == len(db_tables)
assert len(result) == len(db_tables)
assert db_table_names == received_tables
def test_rls_tables_related_api_with_filter_matching_birth(self):
self.login(ADMIN_USERNAME)
# Test with filter that should match 'birth_names'
params = rison.dumps({"filter": "birth", "page": 0, "page_size": 100})
rv = self.client.get(f"/api/v1/rowlevelsecurity/related/tables?q={params}")
assert rv.status_code == 200
data = json.loads(rv.data.decode("utf-8"))
result = data["result"]
received_tables = {table["text"] for table in result}
# Should only return tables with 'birth' in the name
assert all("birth" in table_name.lower() for table_name in received_tables)
assert len(result) >= 1 # At least birth_names should be returned
def test_rls_tables_related_api_with_filter_no_matches(self):
self.login(ADMIN_USERNAME)
# Test with filter that should match nothing
params = rison.dumps({"filter": "nonexistent", "page": 0, "page_size": 100})
rv = self.client.get(f"/api/v1/rowlevelsecurity/related/tables?q={params}")
assert rv.status_code == 200
data = json.loads(rv.data.decode("utf-8"))
result = data["result"]
assert len(result) == 0
assert data["count"] == 0
def test_rls_subjects_related_api(self):
self.login(ADMIN_USERNAME)
params = rison.dumps({"page": 0, "page_size": 100})
rv = self.client.get(f"/api/v1/rowlevelsecurity/related/subjects?q={params}")
assert rv.status_code == 200
data = json.loads(rv.data.decode("utf-8"))
result = data["result"]
assert data["count"] > 0
assert len(result) > 0
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
@pytest.mark.usefixtures("load_energy_table_with_slice")
@mock.patch(
"superset.row_level_security.api.RLSRestApi.base_related_field_filters",
{"tables": [["table_name", filters.FilterStartsWith, "birth"]]},
)
def test_table_related_filter(self):
self.login(ADMIN_USERNAME)
params = rison.dumps({"page": 0, "page_size": 10})
rv = self.client.get(f"/api/v1/rowlevelsecurity/related/tables?q={params}")
assert rv.status_code == 200
data = json.loads(rv.data.decode("utf-8"))
result = data["result"]
received_tables = {table["text"].split(".")[-1] for table in result}
assert data["count"] == 1
assert len(result) == 1
assert {"birth_names"} == received_tables
def test_get_all_related_subjects_with_extra_filters(self):
"""
API: Test get filter related subjects with extra related query filters
"""
self.login(ADMIN_USERNAME)
rv = self.client.get("/api/v1/rowlevelsecurity/related/subjects")
assert rv.status_code == 200
response = json.loads(rv.data.decode("utf-8"))
assert response["count"] > 0
# ---------------------------------------------------------------------------
# Guest token helpers
# ---------------------------------------------------------------------------
RLS_ALICE_REGEX = re.compile(r"name = 'Alice'")
RLS_GENDER_REGEX = re.compile(r"AND \([\s\n]*gender = 'girl'[\s\n]*\)")
def _default_rls_rule():
return {
"dataset": _get_table(name="birth_names").id,
"clause": "name = 'Alice'",
}
def _guest_user_with_rls(rules: Optional[list[Any]] = None) -> GuestUser:
if rules is None:
rules = [_default_rls_rule()]
return security_manager.get_guest_user_from_token(
{
"user": {},
"resources": [
{
"type": GuestTokenResourceType.DASHBOARD,
"id": "06383667-3e02-4e5e-843f-44e9c5896b6c",
}
],
"rls_rules": rules,
"iat": 10,
"exp": 20,
}
)
# ===========================================================================
# Tests: Row Level Security (main filter tests)
# ===========================================================================
@pytest.mark.usefixtures("create_dataset", "rls_filters")
def test_model_view_rls_add_success(admin_client):
test_dataset = _get_test_dataset()
rv = admin_client.post(
"/api/v1/rowlevelsecurity/",
json={
"name": "rls1",
"description": "Some description",
"filter_type": "Regular",
"tables": [test_dataset.id],
"subjects": [_subject_for_role(security_manager.find_role("Alpha")).id],
"group_key": "group_key_1",
"clause": "client_id=1",
},
)
assert rv.status_code == 201
rls1 = (
db.session.query(RowLevelSecurityFilter).filter_by(name="rls1")
).one_or_none()
assert rls1 is not None
# Revert data changes
db.session.delete(rls1)
db.session.commit()
@pytest.mark.usefixtures("create_dataset", "rls_filters")
def test_model_view_rls_add_name_unique(admin_client):
test_dataset = _get_test_dataset()
rv = admin_client.post(
"/api/v1/rowlevelsecurity/",
json={
"name": "rls_entry1",
"description": "Some description",
"filter_type": "Regular",
"tables": [test_dataset.id],
"subjects": [_subject_for_role(security_manager.find_role("Alpha")).id],
"group_key": "group_key_1",
"clause": "client_id=1",
},
)
assert rv.status_code == 422
@pytest.mark.usefixtures("create_dataset", "rls_filters")
def test_model_view_rls_add_tables_required(admin_client):
rv = admin_client.post(
"/api/v1/rowlevelsecurity/",
json={
"name": "rls1",
"description": "Some description",
"filter_type": "Regular",
"tables": [],
"subjects": [_subject_for_role(security_manager.find_role("Alpha")).id],
"group_key": "group_key_1",
"clause": "client_id=1",
},
)
assert rv.status_code == 400
data = json.loads(rv.data.decode("utf-8"))
assert data["message"] == {"tables": ["Shorter than minimum length 1."]}
@pytest.mark.usefixtures("load_energy_table_with_slice", "rls_filters")
def test_rls_filter_alters_energy_query():
g.user = _get_user(username="alpha")
tbl = _get_table(name="energy_usage")
sql = tbl.get_query_str(QUERY_OBJ)
assert tbl.get_extra_cache_keys(QUERY_OBJ) == [1]
assert "value > 1" in sql
@pytest.mark.usefixtures("load_energy_table_with_slice", "rls_filters")
def test_rls_filter_doesnt_alter_energy_query():
g.user = _get_user(username="admin")
tbl = _get_table(name="energy_usage")
sql = tbl.get_query_str(QUERY_OBJ)
assert tbl.get_extra_cache_keys(QUERY_OBJ) == []
assert "value > 1" not in sql
@pytest.mark.usefixtures("load_unicode_dashboard_with_slice", "rls_filters")
def test_multiple_table_filter_alters_another_tables_query():
g.user = _get_user(username="alpha")
tbl = _get_table(name="unicode_test")
sql = tbl.get_query_str(QUERY_OBJ)
assert tbl.get_extra_cache_keys(QUERY_OBJ) == [1]
assert "value > 1" in sql
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices", "rls_filters")
def test_rls_filter_alters_gamma_birth_names_query():
g.user = _get_user(username="gamma")
tbl = _get_table(name="birth_names")
sql = tbl.get_query_str(QUERY_OBJ)
# establish that the filters are grouped together correctly with
# ANDs, ORs and parens in the correct place
normalized_sql = " ".join(
sql.replace("%%", "%").replace("`", "").replace('"', "").split()
)
column_prefix = r"(?:\w+\.)*"
name_column = rf"{column_prefix}name"
gender_column = rf"{column_prefix}gender"
name_ab_filter = (
rf"\(*{name_column}\s+like\s+'A%'\s+or\s+"
rf"{name_column}\s+like\s+'B%'\)*"
)
name_q_filter = rf"\(*{name_column}\s+like\s+'Q%'\)*"
# Filters in the same group can be returned in either order by the metadata DB.
name_filters = (
rf"(?:\(*{name_ab_filter}\s+OR\s+{name_q_filter}\)*|"
rf"\(*{name_q_filter}\s+OR\s+{name_ab_filter}\)*)"
)
base_filter = rf"\(*{gender_column}\s*=\s*'boy'\)*"
expected_filters = {
rf"WHERE\s+\(*{name_filters}\)*\s+AND\s+{base_filter}",
rf"WHERE\s+{base_filter}\s+AND\s+\(*{name_filters}\)*",
}
assert any(
re.search(expected_filter, normalized_sql, flags=re.IGNORECASE)
for expected_filter in expected_filters
), normalized_sql
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices", "rls_filters")
def test_rls_filter_alters_no_role_user_birth_names_query():
g.user = _get_user(username="NoRlsRoleUser")
tbl = _get_table(name="birth_names")
sql = tbl.get_query_str(QUERY_OBJ)
# gamma's filters should not be present query
assert not NAMES_A_REGEX.search(sql)
assert not NAMES_B_REGEX.search(sql)
assert not NAMES_Q_REGEX.search(sql)
# base query should be present
assert BASE_FILTER_REGEX.search(sql)
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices", "rls_filters")
def test_rls_filter_doesnt_alter_admin_birth_names_query():
g.user = _get_user(username="admin")
tbl = _get_table(name="birth_names")
sql = tbl.get_query_str(QUERY_OBJ)
# no filters are applied for admin user
assert not NAMES_A_REGEX.search(sql)
assert not NAMES_B_REGEX.search(sql)
assert not NAMES_Q_REGEX.search(sql)
assert not BASE_FILTER_REGEX.search(sql)
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices", "rls_filters")
def test_get_rls_cache_key():
g.user = _get_user(username="admin")
tbl = _get_table(name="birth_names")
clauses = security_manager.get_rls_cache_key(tbl)
assert clauses == []
g.user = _get_user(username="gamma")
clauses = security_manager.get_rls_cache_key(tbl)
assert clauses == [
"name like 'A%' or name like 'B%'-name",
"name like 'Q%'-name",
"gender = 'boy'-gender",
]
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices", "rls_filters")
def test_rls_filter_applies_to_virtual_dataset():
"""
Test that RLS filters from underlying tables are applied to virtual
datasets.
"""
# Get the physical birth_names table which has RLS filters
physical_table = _get_table(name="birth_names")
# Create a virtual dataset that queries the birth_names table
virtual_dataset = SqlaTable(
table_name="virtual_birth_names",
database=physical_table.database,
schema=physical_table.schema,
sql="SELECT * FROM birth_names",
)
db.session.add(virtual_dataset)
db.session.commit()
try:
# Test as gamma user who has RLS filters
g.user = _get_user(username="gamma")
# Get the SQL query for the virtual dataset
sql = virtual_dataset.get_query_str(QUERY_OBJ)
# Verify that RLS filters from the physical table are applied
# Gamma user should have the name filters (A%, B%, Q%) and gender filter
# Note: SQL uses uppercase LIKE and %% escaping
sql_lower = sql.lower()
assert "name like 'a%" in sql_lower or "name like 'q%" in sql_lower, (
f"RLS name filters not found in virtual dataset query: {sql}"
)
assert "gender = 'boy'" in sql_lower, (
f"RLS gender filter not found in virtual dataset query: {sql}"
)
# Test as admin user who has no RLS filters
g.user = _get_user(username="admin")
sql = virtual_dataset.get_query_str(QUERY_OBJ)
# Admin should not have RLS filters applied
assert not NAMES_A_REGEX.search(sql)
assert not NAMES_B_REGEX.search(sql)
assert not NAMES_Q_REGEX.search(sql)
assert not BASE_FILTER_REGEX.search(sql)
finally:
# Cleanup
db.session.delete(virtual_dataset)
db.session.commit()
@pytest.mark.usefixtures(
"load_birth_names_dashboard_with_slices",
"load_energy_table_with_slice",
"rls_filters",
)
def test_rls_filter_applies_to_virtual_dataset_with_join():
"""
Test that RLS filters are applied when virtual dataset joins
multiple tables.
"""
# Get the physical tables
birth_names_table = _get_table(name="birth_names")
_get_table(name="energy_usage") # Load the table for the test
# Create a virtual dataset with a JOIN query
virtual_dataset = SqlaTable(
table_name="virtual_joined",
database=birth_names_table.database,
schema=birth_names_table.schema,
sql="SELECT b.name, e.value FROM birth_names b JOIN energy_usage e ON 1=1",
)
db.session.add(virtual_dataset)
db.session.commit()
try:
# Test as gamma user who has RLS filters on both tables
g.user = _get_user(username="gamma")
# Get the SQL query for the virtual dataset
sql = virtual_dataset.get_query_str(QUERY_OBJ)
# Verify that RLS filters from both physical tables are applied
# birth_names filters
sql_lower = sql.lower()
assert "name like 'a%" in sql_lower or "name like 'q%" in sql_lower, (
f"birth_names RLS filters not found: {sql}"
)
assert "gender = 'boy'" in sql_lower, (
f"birth_names gender filter not found: {sql}"
)
# energy_usage filter
assert "value > 1" in sql_lower, f"energy_usage RLS filter not found: {sql}"
finally:
# Cleanup
db.session.delete(virtual_dataset)
db.session.commit()
# ===========================================================================
# Tests: Row Level Security Create API
# ===========================================================================
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
def test_create_api_invalid_subject_failure(admin_client):
payload = {
"name": "rls 1",
"clause": "1=1",
"filter_type": "Base",
"tables": [1],
"subjects": [999999],
}
rv = admin_client.post("/api/v1/rowlevelsecurity/", json=payload)
status_code, data = rv.status_code, json.loads(rv.data.decode("utf-8"))
assert status_code == 422
assert data["message"] == "[l'Subjects are invalid']"
def test_create_api_invalid_table_failure(admin_client):
payload = {
"name": "rls 1",
"clause": "1=1",
"filter_type": "Base",
"tables": [999999],
"subjects": [1],
}
rv = admin_client.post("/api/v1/rowlevelsecurity/", json=payload)
status_code, data = rv.status_code, json.loads(rv.data.decode("utf-8"))
assert status_code == 422
assert data["message"] == "[l'Datasource does not exist']"
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
def test_create_api_post_success(admin_client):
table = db.session.query(SqlaTable).first()
subject = _subject_for_role(security_manager.find_role("Admin"))
payload = {
"name": "rls 1",
"clause": "1=1",
"filter_type": "Base",
"tables": [table.id],
"subjects": [subject.id],
}
rv = admin_client.post("/api/v1/rowlevelsecurity/", json=payload)
status_code, data = rv.status_code, json.loads(rv.data.decode("utf-8"))
assert status_code == 201
rls = (
db.session.query(RowLevelSecurityFilter)
.filter(RowLevelSecurityFilter.id == data["id"])
.one_or_none()
)
assert rls
assert rls.name == "rls 1"
assert rls.clause == "1=1"
assert rls.filter_type == "Base"
assert rls.tables[0].id == table.id
assert rls.subjects[0].id == subject.id
db.session.delete(rls)
db.session.commit()
# ===========================================================================
# Tests: Row Level Security Update API
# ===========================================================================
def test_update_api_invalid_id_failure(admin_client):
payload = {
"name": "rls 1",
"clause": "1=1",
"filter_type": "Base",
"tables": [1],
"subjects": [1],
}
rv = admin_client.put("/api/v1/rowlevelsecurity/99999999", json=payload)
status_code, data = rv.status_code, json.loads(rv.data.decode("utf-8"))
assert status_code == 404
assert data["message"] == "Not found"
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
def test_update_api_invalid_subject_failure(admin_client):
table = db.session.query(SqlaTable).first()
rls = RowLevelSecurityFilter(
name="rls test invalid role",
clause="1=1",
filter_type="Regular",
tables=[table],
)
db.session.add(rls)
db.session.commit()
payload = {
"subjects": [999999],
}
rv = admin_client.put(f"/api/v1/rowlevelsecurity/{rls.id}", json=payload)
status_code, data = rv.status_code, json.loads(rv.data.decode("utf-8"))
assert status_code == 422
assert data["message"] == "[l'Subjects are invalid']"
db.session.delete(rls)
db.session.commit()
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
def test_update_api_invalid_table_failure(admin_client):
table = db.session.query(SqlaTable).first()
rls = RowLevelSecurityFilter(
name="rls test invalid role",
clause="1=1",
filter_type="Regular",
tables=[table],
)
db.session.add(rls)
db.session.commit()
payload = {
"name": "rls 1",
"clause": "1=1",
"filter_type": "Base",
"tables": [999999],
"subjects": [1],
}
rv = admin_client.put(f"/api/v1/rowlevelsecurity/{rls.id}", json=payload)
status_code, data = rv.status_code, json.loads(rv.data.decode("utf-8"))
assert status_code == 422
assert data["message"] == "[l'Datasource does not exist']"
db.session.delete(rls)
db.session.commit()
@pytest.mark.usefixtures(
"load_birth_names_dashboard_with_slices", "load_energy_table_with_slice"
)
def test_update_api_put_success(admin_client):
tables = db.session.query(SqlaTable).limit(2).all()
roles = db.session.query(security_manager.role_model).limit(2).all()
role_subjects = [_subject_for_role(role) for role in roles]
rls = RowLevelSecurityFilter(
name="rls 1",
clause="1=1",
filter_type="Regular",
tables=[tables[0]],
subjects=[role_subjects[0]],
)
db.session.add(rls)
db.session.commit()
payload = {
"name": "rls put success",
"clause": "2=2",
"filter_type": "Base",
"tables": [tables[1].id],
"subjects": [role_subjects[1].id],
}
rv = admin_client.put(f"/api/v1/rowlevelsecurity/{rls.id}", json=payload)
status_code, _data = rv.status_code, json.loads(rv.data.decode("utf-8")) # noqa: F841
assert status_code == 200
rls = (
db.session.query(RowLevelSecurityFilter)
.filter(RowLevelSecurityFilter.id == rls.id)
.one_or_none()
)
assert rls.name == "rls put success"
assert rls.clause == "2=2"
assert rls.filter_type == "Base"
assert rls.tables[0].id == tables[1].id
assert rls.subjects[0].role_id == roles[1].id
db.session.delete(rls)
db.session.commit()
# ===========================================================================
# Tests: Row Level Security Delete API
# ===========================================================================
def test_delete_api_invalid_id_failure(admin_client):
ids_to_delete = rison.dumps([10000, 10001, 100002])
rv = admin_client.delete(f"/api/v1/rowlevelsecurity/?q={ids_to_delete}")
status_code, data = rv.status_code, json.loads(rv.data.decode("utf-8"))
assert status_code == 404
assert data["message"] == "Not found"
@pytest.mark.usefixtures(
"load_birth_names_dashboard_with_slices", "load_energy_table_with_slice"
)
def test_delete_api_bulk_delete_success(admin_client):
tables = db.session.query(SqlaTable).limit(2).all()
roles = db.session.query(security_manager.role_model).limit(2).all()
role_subjects = [_subject_for_role(role) for role in roles]
rls_1 = RowLevelSecurityFilter(
name="rls 1",
clause="1=1",
filter_type="Regular",
tables=[tables[0]],
subjects=[role_subjects[0]],
)
rls_2 = RowLevelSecurityFilter(
name="rls 2",
clause="2=2",
filter_type="Base",
tables=[tables[1]],
subjects=[role_subjects[1]],
)
db.session.add_all([rls_1, rls_2])
db.session.commit()
ids_to_delete = rison.dumps([rls_1.id, rls_2.id])
rv = admin_client.delete(f"/api/v1/rowlevelsecurity/?q={ids_to_delete}")
status_code, data = rv.status_code, json.loads(rv.data.decode("utf-8"))
assert status_code == 200
assert data["message"] == "Deleted 2 rules"
# ===========================================================================
# Tests: Row Level Security with Related API
# ===========================================================================
@pytest.mark.usefixtures("load_birth_names_data", "load_energy_table_data")
def test_rls_tables_related_api(admin_client):
params = rison.dumps({"page": 0, "page_size": 100})
rv = admin_client.get(f"/api/v1/rowlevelsecurity/related/tables?q={params}")
assert rv.status_code == 200
data = json.loads(rv.data.decode("utf-8"))
result = data["result"]
db_tables = db.session.query(SqlaTable).all()
db_table_names = {t.name for t in db_tables}
received_tables = {table["text"] for table in result}
assert data["count"] == len(db_tables)
assert len(result) == len(db_tables)
assert db_table_names == received_tables
def test_rls_tables_related_api_with_filter_matching_birth(admin_client):
# Test with filter that should match 'birth_names'
params = rison.dumps({"filter": "birth", "page": 0, "page_size": 100})
rv = admin_client.get(f"/api/v1/rowlevelsecurity/related/tables?q={params}")
assert rv.status_code == 200
data = json.loads(rv.data.decode("utf-8"))
result = data["result"]
received_tables = {table["text"] for table in result}
# Should only return tables with 'birth' in the name
assert all("birth" in table_name.lower() for table_name in received_tables)
assert len(result) >= 1 # At least birth_names should be returned
def test_rls_tables_related_api_with_filter_no_matches(admin_client):
# Test with filter that should match nothing
params = rison.dumps({"filter": "nonexistent", "page": 0, "page_size": 100})
rv = admin_client.get(f"/api/v1/rowlevelsecurity/related/tables?q={params}")
assert rv.status_code == 200
data = json.loads(rv.data.decode("utf-8"))
result = data["result"]
assert len(result) == 0
assert data["count"] == 0
@mock.patch.dict(
"flask.current_app.config",
{
"SUBJECTS_RELATED_TYPES": [SubjectType.USER, SubjectType.GROUP],
"SUBJECTS_RELATED_TYPES_RLS": None,
},
)
def test_rls_subjects_related_api(admin_client):
params = rison.dumps({"page": 0, "page_size": 100})
rv = admin_client.get(f"/api/v1/rowlevelsecurity/related/subjects?q={params}")
assert rv.status_code == 200
data = json.loads(rv.data.decode("utf-8"))
result = data["result"]
assert data["count"] > 0
assert len(result) > 0
assert {subject["extra"]["type"] for subject in result} <= {
SubjectType.USER,
SubjectType.GROUP,
}
@mock.patch.dict(
"flask.current_app.config",
{
"SUBJECTS_RELATED_TYPES": [SubjectType.USER],
"SUBJECTS_RELATED_TYPES_RLS": [SubjectType.ROLE],
},
)
def test_rls_subjects_related_api_entity_override_roles(admin_client):
params = rison.dumps({"page": 0, "page_size": 100})
rv = admin_client.get(f"/api/v1/rowlevelsecurity/related/subjects?q={params}")
assert rv.status_code == 200
data = json.loads(rv.data.decode("utf-8"))
result = data["result"]
assert data["count"] > 0
assert len(result) > 0
assert {subject["extra"]["type"] for subject in result} == {SubjectType.ROLE}
@pytest.mark.usefixtures(
"load_birth_names_dashboard_with_slices", "load_energy_table_with_slice"
)
@mock.patch(
"superset.row_level_security.api.RLSRestApi.base_related_field_filters",
{"tables": [["table_name", filters.FilterStartsWith, "birth"]]},
)
def test_table_related_filter(admin_client):
params = rison.dumps({"page": 0, "page_size": 10})
rv = admin_client.get(f"/api/v1/rowlevelsecurity/related/tables?q={params}")
assert rv.status_code == 200
data = json.loads(rv.data.decode("utf-8"))
result = data["result"]
received_tables = {table["text"].split(".")[-1] for table in result}
assert data["count"] == 1
assert len(result) == 1
assert {"birth_names"} == received_tables
def test_get_all_related_subjects_with_extra_filters(admin_client):
"""
API: Test get filter related subjects with extra related query filters
"""
params = rison.dumps({"page": 0, "page_size": 100})
rv = admin_client.get(f"/api/v1/rowlevelsecurity/related/subjects?q={params}")
assert rv.status_code == 200
response = json.loads(rv.data.decode("utf-8"))
assert response["count"] > 0
# ===========================================================================
# Tests: Guest Token Row Level Security
# ===========================================================================
@mock.patch.dict(
"superset.extensions.feature_flag_manager._feature_flags",
EMBEDDED_SUPERSET=True,
)
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
def test_guest_rls_filter_alters_query():
g.user = _guest_user_with_rls()
tbl = _get_table(name="birth_names")
sql = tbl.get_query_str(QUERY_OBJ)
assert re.search(RLS_ALICE_REGEX, sql)
@mock.patch.dict(
"superset.extensions.feature_flag_manager._feature_flags",
EMBEDDED_SUPERSET=True,
)
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
def test_guest_rls_filter_does_not_alter_unrelated_query():
g.user = _guest_user_with_rls(
rules=[
{
"dataset": _get_table(name="birth_names").id + 1,
"clause": "name = 'Alice'",
}
]
)
tbl = _get_table(name="birth_names")
sql = tbl.get_query_str(QUERY_OBJ)
assert not re.search(RLS_ALICE_REGEX, sql)
@mock.patch.dict(
"superset.extensions.feature_flag_manager._feature_flags",
EMBEDDED_SUPERSET=True,
)
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
def test_guest_multiple_rls_filters_are_unionized():
g.user = _guest_user_with_rls(
rules=[
_default_rls_rule(),
{
"dataset": _get_table(name="birth_names").id,
"clause": "gender = 'girl'",
},
]
)
tbl = _get_table(name="birth_names")
sql = tbl.get_query_str(QUERY_OBJ)
assert re.search(RLS_ALICE_REGEX, sql)
assert re.search(RLS_GENDER_REGEX, sql)
@mock.patch.dict(
"superset.extensions.feature_flag_manager._feature_flags",
EMBEDDED_SUPERSET=True,
)
@pytest.mark.usefixtures(
"load_birth_names_dashboard_with_slices", "load_energy_table_with_slice"
)
def test_guest_rls_filter_for_all_datasets():
births = _get_table(name="birth_names")
energy = _get_table(name="energy_usage")
guest = _guest_user_with_rls(rules=[{"clause": "name = 'Alice'"}])
guest.resources.append({type: "dashboard", id: energy.id})
g.user = guest
births_sql = births.get_query_str(QUERY_OBJ)
energy_sql = energy.get_query_str(QUERY_OBJ)
assert re.search(RLS_ALICE_REGEX, births_sql)
assert re.search(RLS_ALICE_REGEX, energy_sql)
@mock.patch.dict(
"superset.extensions.feature_flag_manager._feature_flags",
EMBEDDED_SUPERSET=True,
)
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
def test_guest_dataset_id_can_be_string():
dataset = _get_table(name="birth_names")
str_id = str(dataset.id)
g.user = _guest_user_with_rls(
rules=[{"dataset": str_id, "clause": "name = 'Alice'"}]
)
sql = dataset.get_query_str(QUERY_OBJ)
assert re.search(RLS_ALICE_REGEX, sql)