Files
superset2/tests/unit_tests/subjects/test_security.py

281 lines
9.0 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 types import SimpleNamespace
from unittest.mock import MagicMock, patch, PropertyMock
import pytest
from superset.subjects.models import Subject
from superset.subjects.types import SubjectType
def _make_subject(id_: int, type_: SubjectType = SubjectType.USER) -> Subject:
s = Subject()
s.id = id_
s.type = type_
return s
def _make_resource(
editor_ids: list[int] | None = None,
viewer_ids: list[int] | None = None,
) -> MagicMock:
"""Mock resource; when *_ids is None the corresponding attr is removed."""
resource = MagicMock()
if editor_ids is not None:
resource.editors = [_make_subject(eid) for eid in editor_ids]
else:
del resource.editors
if viewer_ids is not None:
resource.viewers = [_make_subject(vid) for vid in viewer_ids]
else:
del resource.viewers
return resource
def _make_sm():
from superset.security.manager import SupersetSecurityManager
return SupersetSecurityManager.__new__(SupersetSecurityManager)
def test_is_editor_admin_bypass():
"""Admins are always editors, even with an empty editors list."""
sm = _make_sm()
with patch.object(sm, "is_admin", return_value=True):
assert sm.is_editor(_make_resource(editor_ids=[])) is True
assert sm.is_editor(_make_resource(editor_ids=[99])) is True
def test_is_editor_no_editors_relationship_returns_false():
"""Returns False when the resource lacks an editors relationship."""
sm = _make_sm()
with patch.object(sm, "is_admin", return_value=False):
assert sm.is_editor(_make_resource(editor_ids=None)) is False
def test_get_extra_editor_subject_ids_normalizes_resolver_output():
from superset.security.manager import get_extra_editor_subject_ids
resource = _make_resource()
def resolver(_: object) -> list[object]:
return [
_make_subject(10),
20,
{"id": "30"},
{"id": None},
{"id": "invalid"},
MagicMock(),
20,
]
with (
patch("superset.security.manager.has_app_context", return_value=True),
patch(
"superset.security.manager.current_app",
SimpleNamespace(config={"EXTRA_EDITORS_RESOLVER": resolver}),
),
):
assert get_extra_editor_subject_ids(resource) == [10, 20, 30]
def test_is_editor_extra_editors_resolver_match():
"""Dynamic editor subjects grant edit access."""
sm = _make_sm()
resource = _make_resource(editor_ids=[])
with (
patch.object(sm, "is_admin", return_value=False),
patch("superset.security.manager.get_user_id", return_value=1),
patch(
"superset.subjects.utils.get_user_subject_ids",
return_value=[77],
),
patch(
"superset.security.manager.get_extra_editor_subject_ids",
return_value=[77],
),
):
assert sm.is_editor(resource) is True
@pytest.mark.parametrize(
"user_id,user_subject_ids,editor_ids,expected",
[
(None, [], [10], False), # anonymous
(1, [10], [10, 20], True), # direct user match
(1, [10], [20, 30], False), # no match
(1, [10, 50, 100], [50], True), # role match
(1, [10, 50, 100], [100], True), # group match
(1, [10], [], False), # empty editors
],
ids=["anonymous", "direct", "no-match", "role", "group", "empty-editors"],
)
def test_is_editor(user_id, user_subject_ids, editor_ids, expected):
"""Covers direct user, role, group, anonymous, and empty scenarios."""
sm = _make_sm()
resource = _make_resource(editor_ids=editor_ids)
with (
patch.object(sm, "is_admin", return_value=False),
patch(
"superset.security.manager.get_user_id",
return_value=user_id,
),
patch(
"superset.subjects.utils.get_user_subject_ids",
return_value=user_subject_ids,
),
):
assert sm.is_editor(resource) is expected
def test_raise_for_editorship_requeries_soft_deleted_resource(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Soft-deleted resources are loaded with a scoped visibility bypass."""
from superset.models.helpers import SKIP_VISIBILITY_FILTER_CLASSES
class _SoftDeleteBase:
pass
class _Resource(_SoftDeleteBase):
id: int
requested = _Resource()
requested.id = 123
persisted = _make_resource(editor_ids=[10])
sm = _make_sm()
query = MagicMock()
query.execution_options.return_value.get.return_value = persisted
session = MagicMock()
session.query.return_value = query
monkeypatch.setattr("superset.models.helpers.SoftDeleteMixin", _SoftDeleteBase)
with (
patch.object(type(sm), "session", new_callable=PropertyMock) as mock_session,
patch.object(sm, "is_admin", return_value=False),
patch("superset.security.manager.get_user_id", return_value=1),
patch(
"superset.subjects.utils.get_user_subject_ids",
return_value=[10],
),
):
mock_session.return_value = session
sm.raise_for_editorship(requested)
session.query.assert_called_once_with(_Resource)
query.execution_options.assert_called_once_with(
**{SKIP_VISIBILITY_FILTER_CLASSES: {_Resource}}
)
query.execution_options.return_value.get.assert_called_once_with(123)
# ---------------------------------------------------------------------------
# is_viewer tests
# ---------------------------------------------------------------------------
def test_is_viewer_admin_bypass():
"""Admins are always viewers."""
sm = _make_sm()
with patch.object(sm, "is_admin", return_value=True):
assert sm.is_viewer(_make_resource(editor_ids=[], viewer_ids=[])) is True
def test_is_viewer_anonymous():
"""Anonymous users are never viewers."""
sm = _make_sm()
with (
patch.object(sm, "is_admin", return_value=False),
patch("superset.security.manager.get_user_id", return_value=None),
):
assert sm.is_viewer(_make_resource(editor_ids=[10], viewer_ids=[10])) is False
def test_is_viewer_extra_editors_resolver_match():
"""Dynamic editor subjects also imply view access."""
sm = _make_sm()
resource = _make_resource(editor_ids=[], viewer_ids=[])
with (
patch.object(sm, "is_admin", return_value=False),
patch("superset.security.manager.get_user_id", return_value=1),
patch(
"superset.subjects.utils.get_user_subject_ids",
return_value=[77],
),
patch(
"superset.security.manager.get_extra_editor_subject_ids",
return_value=[77],
),
):
assert sm.is_viewer(resource) is True
@pytest.mark.parametrize(
"user_subject_ids,editor_ids,viewer_ids,expected",
[
([10], [10], [], True), # editor implies viewer
([10], [], [10], True), # direct viewer match
([10], [20], [10], True), # viewer match (not editor)
([10, 50], [50], [20], True), # editor via role subject
([10], [20], [30], False), # no match at all
([10], [], [], False), # empty editors + viewers
],
ids=[
"editor-implies-viewer",
"viewer-only",
"viewer-not-editor",
"editor-role-match",
"no-match",
"empty-both",
],
)
def test_is_viewer(user_subject_ids, editor_ids, viewer_ids, expected):
sm = _make_sm()
resource = _make_resource(editor_ids=editor_ids, viewer_ids=viewer_ids)
with (
patch.object(sm, "is_admin", return_value=False),
patch("superset.security.manager.get_user_id", return_value=1),
patch(
"superset.subjects.utils.get_user_subject_ids",
return_value=user_subject_ids,
),
):
assert sm.is_viewer(resource) is expected
def test_is_viewer_missing_relationships():
"""is_viewer returns False (no raise) when editors/viewers attrs missing."""
sm = _make_sm()
resource = _make_resource(editor_ids=None, viewer_ids=None)
with (
patch.object(sm, "is_admin", return_value=False),
patch("superset.security.manager.get_user_id", return_value=1),
patch(
"superset.subjects.utils.get_user_subject_ids",
return_value=[10],
),
):
assert sm.is_viewer(resource) is False