mirror of
https://github.com/apache/superset.git
synced 2026-07-09 08:15:49 +00:00
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
288 lines
9.7 KiB
Python
288 lines
9.7 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.
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from superset.commands.security.create import CreateRLSRuleCommand
|
|
from superset.commands.security.delete import DeleteRLSRuleCommand
|
|
from superset.commands.security.exceptions import RLSDatasourceForbiddenError
|
|
from superset.commands.security.update import UpdateRLSRuleCommand
|
|
|
|
|
|
def _mock_tables(*table_ids: int) -> list[MagicMock]:
|
|
tables = []
|
|
for table_id in table_ids:
|
|
table = MagicMock()
|
|
table.id = table_id
|
|
tables.append(table)
|
|
return tables
|
|
|
|
|
|
def _patch_query(module: str, tables: list[MagicMock]):
|
|
"""Patch db.session.query(...).filter(...).all() to return ``tables``."""
|
|
query = MagicMock()
|
|
query.filter.return_value.all.return_value = tables
|
|
return patch(f"{module}.db.session.query", return_value=query)
|
|
|
|
|
|
def test_create_rls_rule_forbidden_when_no_datasource_access() -> None:
|
|
tables = _mock_tables(1)
|
|
|
|
with (
|
|
_patch_query("superset.commands.security.create", tables),
|
|
patch(
|
|
"superset.commands.security.create.populate_roles",
|
|
return_value=[],
|
|
),
|
|
patch(
|
|
"superset.commands.security.utils.security_manager.can_access_datasource",
|
|
return_value=False,
|
|
) as can_access,
|
|
):
|
|
command = CreateRLSRuleCommand({"tables": [1], "roles": []})
|
|
with pytest.raises(RLSDatasourceForbiddenError):
|
|
command.validate()
|
|
|
|
can_access.assert_called_once_with(datasource=tables[0])
|
|
|
|
|
|
def test_create_rls_rule_allowed_when_datasource_access() -> None:
|
|
tables = _mock_tables(1, 2)
|
|
|
|
with (
|
|
_patch_query("superset.commands.security.create", tables),
|
|
patch(
|
|
"superset.commands.security.create.populate_roles",
|
|
return_value=[],
|
|
),
|
|
patch(
|
|
"superset.commands.security.utils.security_manager.can_access_datasource",
|
|
return_value=True,
|
|
) as can_access,
|
|
):
|
|
command = CreateRLSRuleCommand({"tables": [1, 2], "roles": []})
|
|
command.validate()
|
|
|
|
# Access is checked for every referenced datasource.
|
|
assert can_access.call_count == 2
|
|
assert command._properties["tables"] == tables
|
|
|
|
|
|
def test_create_rls_rule_forbidden_if_any_datasource_denied() -> None:
|
|
tables = _mock_tables(1, 2)
|
|
|
|
with (
|
|
_patch_query("superset.commands.security.create", tables),
|
|
patch(
|
|
"superset.commands.security.create.populate_roles",
|
|
return_value=[],
|
|
),
|
|
patch(
|
|
"superset.commands.security.utils.security_manager.can_access_datasource",
|
|
side_effect=[True, False],
|
|
),
|
|
):
|
|
command = CreateRLSRuleCommand({"tables": [1, 2], "roles": []})
|
|
with pytest.raises(RLSDatasourceForbiddenError):
|
|
command.validate()
|
|
|
|
|
|
def test_update_rls_rule_forbidden_when_no_datasource_access() -> None:
|
|
tables = _mock_tables(1)
|
|
|
|
with (
|
|
_patch_query("superset.commands.security.update", tables),
|
|
patch(
|
|
"superset.commands.security.update.RLSDAO.find_by_id",
|
|
return_value=MagicMock(),
|
|
),
|
|
patch(
|
|
"superset.commands.security.update.populate_roles",
|
|
return_value=[],
|
|
),
|
|
patch(
|
|
"superset.commands.security.utils.security_manager.can_access_datasource",
|
|
return_value=False,
|
|
) as can_access,
|
|
):
|
|
command = UpdateRLSRuleCommand(1, {"tables": [1], "roles": []})
|
|
with pytest.raises(RLSDatasourceForbiddenError):
|
|
command.validate()
|
|
|
|
can_access.assert_called_once_with(datasource=tables[0])
|
|
|
|
|
|
def test_update_rls_rule_allowed_when_datasource_access() -> None:
|
|
tables = _mock_tables(1)
|
|
|
|
with (
|
|
_patch_query("superset.commands.security.update", tables),
|
|
patch(
|
|
"superset.commands.security.update.RLSDAO.find_by_id",
|
|
return_value=MagicMock(),
|
|
),
|
|
patch(
|
|
"superset.commands.security.update.populate_roles",
|
|
return_value=[],
|
|
),
|
|
patch(
|
|
"superset.commands.security.utils.security_manager.can_access_datasource",
|
|
return_value=True,
|
|
) as can_access,
|
|
):
|
|
command = UpdateRLSRuleCommand(1, {"tables": [1], "roles": []})
|
|
command.validate()
|
|
|
|
can_access.assert_called_once_with(datasource=tables[0])
|
|
assert command._properties["tables"] == tables
|
|
|
|
|
|
def test_update_rls_rule_partial_update_preserves_tables_and_roles() -> None:
|
|
"""A partial update without tables/roles must not clear those bindings.
|
|
|
|
When the request body omits ``tables``/``roles``, validate() must not add
|
|
those keys to the properties passed to the DAO, so the existing bindings
|
|
are left untouched instead of being overwritten with empty lists.
|
|
"""
|
|
rule = MagicMock()
|
|
rule.tables = _mock_tables(1)
|
|
with (
|
|
patch(
|
|
"superset.commands.security.update.RLSDAO.find_by_id",
|
|
return_value=rule,
|
|
),
|
|
patch(
|
|
"superset.commands.security.update.populate_roles",
|
|
) as populate_roles,
|
|
patch("superset.commands.security.update.db.session.query") as query,
|
|
patch(
|
|
"superset.commands.security.utils.security_manager.can_access_datasource",
|
|
return_value=True,
|
|
),
|
|
):
|
|
command = UpdateRLSRuleCommand(1, {"name": "new name"})
|
|
command.validate()
|
|
|
|
# Omitted relationships are not resolved or written back.
|
|
populate_roles.assert_not_called()
|
|
query.assert_not_called()
|
|
assert "tables" not in command._properties
|
|
assert "roles" not in command._properties
|
|
assert command._properties["name"] == "new name"
|
|
|
|
|
|
def test_update_rls_rule_only_roles_present_does_not_touch_tables() -> None:
|
|
"""Updating only ``roles`` must not resolve or overwrite ``tables``."""
|
|
rule = MagicMock()
|
|
rule.tables = _mock_tables(1)
|
|
with (
|
|
patch(
|
|
"superset.commands.security.update.RLSDAO.find_by_id",
|
|
return_value=rule,
|
|
),
|
|
patch(
|
|
"superset.commands.security.update.populate_roles",
|
|
return_value=["resolved-role"],
|
|
) as populate_roles,
|
|
patch("superset.commands.security.update.db.session.query") as query,
|
|
patch(
|
|
"superset.commands.security.utils.security_manager.can_access_datasource",
|
|
return_value=True,
|
|
),
|
|
):
|
|
command = UpdateRLSRuleCommand(1, {"roles": [1]})
|
|
command.validate()
|
|
|
|
populate_roles.assert_called_once()
|
|
query.assert_not_called()
|
|
assert command._properties["roles"] == ["resolved-role"]
|
|
assert "tables" not in command._properties
|
|
|
|
|
|
def test_update_rls_rule_partial_update_enforces_access_on_existing_tables() -> None:
|
|
"""A partial update that omits ``tables`` still enforces datasource access.
|
|
|
|
The rule's existing table bindings must be authorized so a caller cannot
|
|
edit a rule tied to datasources they cannot access by simply omitting
|
|
``tables`` from the payload.
|
|
"""
|
|
rule = MagicMock()
|
|
rule.tables = _mock_tables(1)
|
|
with (
|
|
patch(
|
|
"superset.commands.security.update.RLSDAO.find_by_id",
|
|
return_value=rule,
|
|
),
|
|
patch("superset.commands.security.update.db.session.query") as query,
|
|
patch(
|
|
"superset.commands.security.utils.security_manager.can_access_datasource",
|
|
return_value=False,
|
|
) as can_access,
|
|
):
|
|
command = UpdateRLSRuleCommand(1, {"name": "new name"})
|
|
with pytest.raises(RLSDatasourceForbiddenError):
|
|
command.validate()
|
|
|
|
# Access is checked against the rule's existing tables, not a submitted set.
|
|
can_access.assert_called_once_with(datasource=rule.tables[0])
|
|
query.assert_not_called()
|
|
|
|
|
|
def test_delete_rls_rule_forbidden_when_no_datasource_access() -> None:
|
|
tables = _mock_tables(1)
|
|
rule = MagicMock()
|
|
rule.tables = tables
|
|
|
|
with (
|
|
patch(
|
|
"superset.commands.security.delete.RLSDAO.find_by_ids",
|
|
return_value=[rule],
|
|
),
|
|
patch(
|
|
"superset.commands.security.utils.security_manager.can_access_datasource",
|
|
return_value=False,
|
|
) as can_access,
|
|
):
|
|
command = DeleteRLSRuleCommand([1])
|
|
with pytest.raises(RLSDatasourceForbiddenError):
|
|
command.validate()
|
|
|
|
can_access.assert_called_once_with(datasource=tables[0])
|
|
|
|
|
|
def test_delete_rls_rule_allowed_when_datasource_access() -> None:
|
|
tables = _mock_tables(1, 2)
|
|
rule = MagicMock()
|
|
rule.tables = tables
|
|
|
|
with (
|
|
patch(
|
|
"superset.commands.security.delete.RLSDAO.find_by_ids",
|
|
return_value=[rule],
|
|
),
|
|
patch(
|
|
"superset.commands.security.utils.security_manager.can_access_datasource",
|
|
return_value=True,
|
|
) as can_access,
|
|
):
|
|
command = DeleteRLSRuleCommand([1])
|
|
command.validate()
|
|
|
|
# Access is checked for every datasource referenced by the rule.
|
|
assert can_access.call_count == 2
|