mirror of
https://github.com/apache/superset.git
synced 2026-07-29 10:02:32 +00:00
591 lines
20 KiB
Python
591 lines
20 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 datetime import datetime, timedelta, timezone
|
|
from unittest import mock
|
|
from unittest.mock import ANY, Mock
|
|
|
|
from flask import g
|
|
from jwt import encode
|
|
from pytest import fixture, mark, raises # noqa: PT013
|
|
|
|
from superset import security_manager
|
|
from superset.async_events.async_query_manager import (
|
|
AsyncQueryJobException,
|
|
AsyncQueryManager,
|
|
AsyncQueryTokenException,
|
|
)
|
|
from superset.async_events.cache_backend import (
|
|
RedisCacheBackend,
|
|
RedisSentinelCacheBackend,
|
|
)
|
|
from superset.utils import json
|
|
|
|
JWT_TOKEN_SECRET = "some_secret" # noqa: S105
|
|
JWT_TOKEN_COOKIE_NAME = "superset_async_jwt" # noqa: S105
|
|
|
|
|
|
@fixture
|
|
def async_query_manager():
|
|
query_manager = AsyncQueryManager()
|
|
query_manager._jwt_secret = JWT_TOKEN_SECRET
|
|
query_manager._jwt_cookie_name = JWT_TOKEN_COOKIE_NAME
|
|
query_manager._jwt_expiration_seconds = 3600
|
|
return query_manager
|
|
|
|
|
|
def set_current_as_guest_user():
|
|
g.user = security_manager.get_guest_user_from_token(
|
|
{
|
|
"user": {},
|
|
"resources": [{"type": "dashboard", "id": "some-uuid"}],
|
|
"rls_rules": [{"clause": '"STATEID" = 3'}],
|
|
"iat": 1700000000.0,
|
|
"exp": 1700000300.0,
|
|
"aud": "http://0.0.0.0:8080/",
|
|
"type": "guest",
|
|
}
|
|
)
|
|
|
|
|
|
def test_parse_channel_id_from_request(async_query_manager):
|
|
encoded_token = encode(
|
|
{"channel": "test_channel_id"}, JWT_TOKEN_SECRET, algorithm="HS256"
|
|
)
|
|
|
|
request = Mock()
|
|
request.cookies = {"superset_async_jwt": encoded_token}
|
|
|
|
assert (
|
|
async_query_manager.parse_channel_id_from_request(request) == "test_channel_id"
|
|
)
|
|
|
|
|
|
def test_parse_channel_id_from_request_with_valid_exp(async_query_manager):
|
|
"""A token with a future exp claim is accepted."""
|
|
encoded_token = encode(
|
|
{
|
|
"channel": "test_channel_id",
|
|
"exp": datetime.now(tz=timezone.utc) + timedelta(hours=1),
|
|
},
|
|
JWT_TOKEN_SECRET,
|
|
algorithm="HS256",
|
|
)
|
|
|
|
request = Mock()
|
|
request.cookies = {"superset_async_jwt": encoded_token}
|
|
|
|
assert (
|
|
async_query_manager.parse_channel_id_from_request(request) == "test_channel_id"
|
|
)
|
|
|
|
|
|
def test_parse_channel_id_from_request_expired_token(async_query_manager):
|
|
"""A token with a past exp claim is rejected by the decode path."""
|
|
encoded_token = encode(
|
|
{
|
|
"channel": "test_channel_id",
|
|
"exp": datetime.now(tz=timezone.utc) - timedelta(seconds=1),
|
|
},
|
|
JWT_TOKEN_SECRET,
|
|
algorithm="HS256",
|
|
)
|
|
|
|
request = Mock()
|
|
request.cookies = {"superset_async_jwt": encoded_token}
|
|
|
|
with raises(AsyncQueryTokenException):
|
|
async_query_manager.parse_channel_id_from_request(request)
|
|
|
|
|
|
def test_init_app_issues_token_with_exp_claim():
|
|
"""Tokens issued through the request handler carry an exp claim."""
|
|
import jwt
|
|
|
|
app = Mock()
|
|
app.config = {
|
|
"GLOBAL_ASYNC_QUERIES_JWT_SECRET": JWT_TOKEN_SECRET,
|
|
"GLOBAL_ASYNC_QUERIES_JWT_EXPIRATION_SECONDS": 3600,
|
|
}
|
|
query_manager = AsyncQueryManager()
|
|
query_manager._jwt_secret = app.config["GLOBAL_ASYNC_QUERIES_JWT_SECRET"]
|
|
query_manager._jwt_expiration_seconds = app.config[
|
|
"GLOBAL_ASYNC_QUERIES_JWT_EXPIRATION_SECONDS"
|
|
]
|
|
|
|
before = datetime.now(tz=timezone.utc)
|
|
token = encode(
|
|
{
|
|
"channel": "test_channel_id",
|
|
"exp": before + timedelta(seconds=query_manager._jwt_expiration_seconds),
|
|
},
|
|
query_manager._jwt_secret,
|
|
algorithm="HS256",
|
|
)
|
|
decoded = jwt.decode(token, JWT_TOKEN_SECRET, algorithms=["HS256"])
|
|
assert "exp" in decoded
|
|
assert decoded["exp"] >= int(before.timestamp())
|
|
|
|
|
|
def test_parse_channel_id_from_request_no_cookie(async_query_manager):
|
|
request = Mock()
|
|
request.cookies = {}
|
|
|
|
with raises(AsyncQueryTokenException):
|
|
async_query_manager.parse_channel_id_from_request(request)
|
|
|
|
|
|
def test_parse_channel_id_from_request_bad_jwt(async_query_manager):
|
|
request = Mock()
|
|
request.cookies = {"superset_async_jwt": "bad_jwt"}
|
|
|
|
with raises(AsyncQueryTokenException):
|
|
async_query_manager.parse_channel_id_from_request(request)
|
|
|
|
|
|
@mock.patch("superset.is_feature_enabled")
|
|
def test_parse_channel_id_from_request_as_guest_user_no_cookie(
|
|
is_feature_enabled_mock, async_query_manager
|
|
):
|
|
"""
|
|
Embedded guest sessions cannot rely on the async-token cookie because
|
|
cross-origin cookies are blocked or stripped by modern browsers when the
|
|
dashboard is rendered inside a third-party iframe. The channel id must
|
|
therefore be derived from the guest token rather than the cookie.
|
|
"""
|
|
is_feature_enabled_mock.return_value = True
|
|
set_current_as_guest_user()
|
|
|
|
request = Mock()
|
|
request.cookies = {}
|
|
|
|
channel_id = async_query_manager.parse_channel_id_from_request(request)
|
|
assert channel_id.startswith("guest-")
|
|
|
|
|
|
@mock.patch("superset.is_feature_enabled")
|
|
def test_parse_channel_id_from_request_as_guest_user_is_deterministic(
|
|
is_feature_enabled_mock, async_query_manager
|
|
):
|
|
"""
|
|
The same guest token (including its RLS rules) must yield the same channel
|
|
id across requests. Otherwise the chart-data submission and the polling
|
|
endpoint would write to and read from different streams, returning 401s
|
|
even though the work was scheduled correctly.
|
|
"""
|
|
is_feature_enabled_mock.return_value = True
|
|
set_current_as_guest_user()
|
|
|
|
request = Mock()
|
|
request.cookies = {}
|
|
|
|
first = async_query_manager.parse_channel_id_from_request(request)
|
|
second = async_query_manager.parse_channel_id_from_request(request)
|
|
assert first == second
|
|
|
|
|
|
@mock.patch("superset.is_feature_enabled")
|
|
def test_parse_channel_id_from_request_as_guest_user_differs_per_token(
|
|
is_feature_enabled_mock, async_query_manager
|
|
):
|
|
"""Different guest tokens must produce different channel ids."""
|
|
is_feature_enabled_mock.return_value = True
|
|
|
|
set_current_as_guest_user()
|
|
request = Mock()
|
|
request.cookies = {}
|
|
first = async_query_manager.parse_channel_id_from_request(request)
|
|
|
|
g.user = security_manager.get_guest_user_from_token(
|
|
{
|
|
"user": {"username": "other"},
|
|
"resources": [{"type": "dashboard", "id": "another-uuid"}],
|
|
"rls_rules": [{"clause": '"STATEID" = 4'}],
|
|
"iat": 1700000000.0,
|
|
"exp": 1700000300.0,
|
|
"aud": "http://0.0.0.0:8080/",
|
|
"type": "guest",
|
|
}
|
|
)
|
|
second = async_query_manager.parse_channel_id_from_request(request)
|
|
|
|
assert first != second
|
|
|
|
|
|
@mock.patch("superset.is_feature_enabled")
|
|
def test_parse_channel_id_from_request_as_guest_user_differs_per_scope(
|
|
is_feature_enabled_mock, async_query_manager
|
|
):
|
|
"""
|
|
Tokens that differ only in the optional ``datasets`` allowlist or ``rev``
|
|
revocation version must still derive distinct channel ids, otherwise two
|
|
differently scoped embedded sessions would collide on the same stream.
|
|
"""
|
|
is_feature_enabled_mock.return_value = True
|
|
|
|
base_token = {
|
|
"user": {},
|
|
"resources": [{"type": "dashboard", "id": "some-uuid"}],
|
|
"rls_rules": [{"clause": '"STATEID" = 3'}],
|
|
"iat": 1700000000.0,
|
|
"exp": 1700000300.0,
|
|
"aud": "http://0.0.0.0:8080/",
|
|
"type": "guest",
|
|
}
|
|
|
|
request = Mock()
|
|
request.cookies = {}
|
|
|
|
g.user = security_manager.get_guest_user_from_token(dict(base_token))
|
|
baseline = async_query_manager.parse_channel_id_from_request(request)
|
|
|
|
g.user = security_manager.get_guest_user_from_token({**base_token, "datasets": [1]})
|
|
with_datasets = async_query_manager.parse_channel_id_from_request(request)
|
|
|
|
g.user = security_manager.get_guest_user_from_token({**base_token, "rev": 1})
|
|
with_rev = async_query_manager.parse_channel_id_from_request(request)
|
|
|
|
assert baseline != with_datasets
|
|
assert baseline != with_rev
|
|
assert with_datasets != with_rev
|
|
|
|
|
|
@mark.parametrize(
|
|
"cache_type, cache_backend",
|
|
[
|
|
("RedisCacheBackend", mock.Mock(spec=RedisCacheBackend)),
|
|
("RedisSentinelCacheBackend", mock.Mock(spec=RedisSentinelCacheBackend)),
|
|
],
|
|
)
|
|
@mock.patch("superset.is_feature_enabled")
|
|
def test_submit_chart_data_job_as_guest_user(
|
|
is_feature_enabled_mock, async_query_manager, cache_type, cache_backend
|
|
):
|
|
is_feature_enabled_mock.return_value = True
|
|
set_current_as_guest_user()
|
|
|
|
# Mock the get_cache_backend method to return the current cache backend
|
|
async_query_manager.get_cache_backend = mock.Mock(return_value=cache_backend)
|
|
|
|
job_mock = Mock()
|
|
async_query_manager._load_chart_data_into_cache_job = job_mock
|
|
job_meta = async_query_manager.submit_chart_data_job(
|
|
channel_id="test_channel_id",
|
|
form_data={},
|
|
)
|
|
|
|
job_mock.apply_async.assert_called_once_with(
|
|
args=[
|
|
{
|
|
"channel_id": "test_channel_id",
|
|
"errors": [],
|
|
"guest_token": {
|
|
"user": {},
|
|
"resources": [{"type": "dashboard", "id": "some-uuid"}],
|
|
"rls_rules": [{"clause": '"STATEID" = 3'}],
|
|
"iat": 1700000000.0,
|
|
"exp": 1700000300.0,
|
|
"aud": "http://0.0.0.0:8080/",
|
|
"type": "guest",
|
|
},
|
|
"job_id": ANY,
|
|
"result_url": None,
|
|
"status": "pending",
|
|
"user_id": None,
|
|
},
|
|
{},
|
|
],
|
|
task_id=ANY,
|
|
expires=3600,
|
|
)
|
|
|
|
assert "guest_token" not in job_meta
|
|
job_mock.reset_mock() # Reset the mock for the next iteration
|
|
|
|
|
|
def test_parse_channel_id_from_request_sub_none(async_query_manager):
|
|
"""Regression: token with sub=None must not break parse (PyJWT 2.10.1+)."""
|
|
encoded_token = encode(
|
|
{"channel": "test_channel_id", "sub": None},
|
|
JWT_TOKEN_SECRET,
|
|
algorithm="HS256",
|
|
)
|
|
|
|
request = Mock()
|
|
request.cookies = {JWT_TOKEN_COOKIE_NAME: encoded_token}
|
|
|
|
with raises(AsyncQueryTokenException):
|
|
async_query_manager.parse_channel_id_from_request(request)
|
|
|
|
|
|
def test_validate_session_guest_user_creates_valid_token(async_query_manager):
|
|
"""Regression: validate_session creates decodable tokens when user_id is None."""
|
|
from flask import Flask
|
|
|
|
async_query_manager._jwt_cookie_secure = False
|
|
async_query_manager._jwt_cookie_domain = None
|
|
async_query_manager._jwt_cookie_samesite = "Lax"
|
|
async_query_manager._jwt_expiration_seconds = 3600
|
|
|
|
app = Flask(__name__)
|
|
app.secret_key = "test_secret_key_for_testing" # noqa: S105
|
|
async_query_manager.register_request_handlers(app)
|
|
|
|
@app.route("/test")
|
|
def test_view():
|
|
return "ok"
|
|
|
|
with mock.patch(
|
|
"superset.async_events.async_query_manager.get_user_id",
|
|
return_value=None,
|
|
):
|
|
client = app.test_client()
|
|
resp = client.get("/test")
|
|
|
|
cookie_header = [
|
|
v
|
|
for k, v in resp.headers
|
|
if k == "Set-Cookie" and JWT_TOKEN_COOKIE_NAME in v
|
|
]
|
|
assert cookie_header, "JWT cookie was not set"
|
|
token = cookie_header[0].split("=", 1)[1].split(";")[0]
|
|
|
|
mock_request = Mock()
|
|
mock_request.cookies = {JWT_TOKEN_COOKIE_NAME: token}
|
|
channel = async_query_manager.parse_channel_id_from_request(mock_request)
|
|
assert channel # valid UUID string
|
|
|
|
|
|
@mark.parametrize(
|
|
"cache_type, cache_backend",
|
|
[
|
|
("RedisCacheBackend", mock.Mock(spec=RedisCacheBackend)),
|
|
("RedisSentinelCacheBackend", mock.Mock(spec=RedisSentinelCacheBackend)),
|
|
],
|
|
)
|
|
@mock.patch("superset.is_feature_enabled")
|
|
def test_submit_explore_json_job_as_guest_user(
|
|
is_feature_enabled_mock, async_query_manager, cache_type, cache_backend
|
|
):
|
|
is_feature_enabled_mock.return_value = True
|
|
set_current_as_guest_user()
|
|
|
|
# Mock the get_cache_backend method to return the current cache backend
|
|
async_query_manager.get_cache_backend = mock.Mock(return_value=cache_backend)
|
|
|
|
job_mock = Mock()
|
|
async_query_manager._load_explore_json_into_cache_job = job_mock
|
|
job_meta = async_query_manager.submit_explore_json_job(
|
|
channel_id="test_channel_id",
|
|
form_data={},
|
|
response_type="json",
|
|
)
|
|
|
|
job_mock.apply_async.assert_called_once_with(
|
|
args=[
|
|
{
|
|
"channel_id": "test_channel_id",
|
|
"errors": [],
|
|
"guest_token": {
|
|
"user": {},
|
|
"resources": [{"type": "dashboard", "id": "some-uuid"}],
|
|
"rls_rules": [{"clause": '"STATEID" = 3'}],
|
|
"iat": 1700000000.0,
|
|
"exp": 1700000300.0,
|
|
"aud": "http://0.0.0.0:8080/",
|
|
"type": "guest",
|
|
},
|
|
"job_id": ANY,
|
|
"result_url": None,
|
|
"status": "pending",
|
|
"user_id": None,
|
|
},
|
|
{},
|
|
"json",
|
|
False,
|
|
],
|
|
task_id=ANY,
|
|
expires=3600,
|
|
)
|
|
|
|
assert "guest_token" not in job_meta
|
|
|
|
|
|
@fixture
|
|
def cancellable_manager():
|
|
"""A manager wired to a mock Redis backend for cancellation tests."""
|
|
manager = AsyncQueryManager()
|
|
manager._jwt_expiration_seconds = 3600
|
|
manager._stream_prefix = "async-events-"
|
|
manager._cache = mock.Mock(spec=RedisCacheBackend)
|
|
return manager
|
|
|
|
|
|
def test_init_job_registers_cancellable_record(cancellable_manager):
|
|
"""init_job persists the owner identity a later cancel must match."""
|
|
cancellable_manager.init_job("chan-1", 7)
|
|
|
|
cancellable_manager._cache.set.assert_called_once()
|
|
key, value = cancellable_manager._cache.set.call_args.args
|
|
assert key.startswith("async-events-job-cancel:")
|
|
assert json.loads(value) == {"channel_id": "chan-1", "user_id": 7}
|
|
|
|
|
|
def test_cancel_job_authorized_revokes_task(cancellable_manager):
|
|
cancellable_manager._stream_limit = 100
|
|
cancellable_manager._stream_limit_firehose = 1000
|
|
cancellable_manager._cache.get.return_value = json.dumps(
|
|
{"channel_id": "chan-1", "user_id": 7}
|
|
)
|
|
cancellable_manager._cache.set.return_value = True
|
|
|
|
with mock.patch("superset.extensions.celery_app") as celery_app:
|
|
cancellable_manager.cancel_job("job-1", "chan-1", 7)
|
|
|
|
celery_app.control.revoke.assert_called_once_with(
|
|
"job-1", terminate=True, signal="SIGUSR1"
|
|
)
|
|
# The job is flagged cancelled (conditionally, xx=True) so the worker knows
|
|
# what the signal it is about to receive means.
|
|
assert cancellable_manager._cache.set.call_args.kwargs["xx"] is True
|
|
flagged = json.loads(cancellable_manager._cache.set.call_args.args[1])
|
|
assert flagged["cancelled"] is True
|
|
|
|
|
|
def test_cancel_job_emits_the_terminal_event(cancellable_manager):
|
|
"""A task revoked before a worker picks it up never reports on itself."""
|
|
cancellable_manager._stream_limit = 100
|
|
cancellable_manager._stream_limit_firehose = 1000
|
|
cancellable_manager._cache.get.return_value = json.dumps(
|
|
{"channel_id": "chan-1", "user_id": 7}
|
|
)
|
|
cancellable_manager._cache.set.return_value = True
|
|
|
|
with mock.patch("superset.extensions.celery_app"):
|
|
cancellable_manager.cancel_job("job-1", "chan-1", 7)
|
|
|
|
scoped_stream, event_data = cancellable_manager._cache.xadd.call_args_list[0].args[
|
|
:2
|
|
]
|
|
assert scoped_stream == "async-events-chan-1"
|
|
assert json.loads(event_data["data"]) == {
|
|
"channel_id": "chan-1",
|
|
"job_id": "job-1",
|
|
"user_id": 7,
|
|
"status": AsyncQueryManager.STATUS_CANCELLED,
|
|
"errors": [],
|
|
"result_url": None,
|
|
}
|
|
# the record has to outlive the event: the worker still needs to recognize
|
|
# the signal on its way as a cancellation
|
|
cancellable_manager._cache.delete.assert_not_called()
|
|
|
|
|
|
def test_cancel_job_completed_between_read_and_flag(cancellable_manager):
|
|
"""If the job's record is cleared after the auth read, don't revoke."""
|
|
cancellable_manager._cache.get.return_value = json.dumps(
|
|
{"channel_id": "chan-1", "user_id": 7}
|
|
)
|
|
# Conditional (xx) write finds no key: the job finished and cleaned up.
|
|
cancellable_manager._cache.set.return_value = None
|
|
|
|
with (
|
|
mock.patch("superset.extensions.celery_app") as celery_app,
|
|
raises(AsyncQueryJobException),
|
|
):
|
|
cancellable_manager.cancel_job("job-1", "chan-1", 7)
|
|
|
|
celery_app.control.revoke.assert_not_called()
|
|
|
|
|
|
def test_cancel_job_wrong_user_is_rejected(cancellable_manager):
|
|
cancellable_manager._cache.get.return_value = json.dumps(
|
|
{"channel_id": "chan-1", "user_id": 7}
|
|
)
|
|
|
|
with (
|
|
mock.patch("superset.extensions.celery_app") as celery_app,
|
|
raises(AsyncQueryTokenException),
|
|
):
|
|
cancellable_manager.cancel_job("job-1", "chan-1", 999)
|
|
|
|
celery_app.control.revoke.assert_not_called()
|
|
|
|
|
|
def test_cancel_job_wrong_channel_is_rejected(cancellable_manager):
|
|
"""A matching user on a different channel still cannot cancel the job."""
|
|
cancellable_manager._cache.get.return_value = json.dumps(
|
|
{"channel_id": "chan-1", "user_id": 7}
|
|
)
|
|
|
|
with (
|
|
mock.patch("superset.extensions.celery_app") as celery_app,
|
|
raises(AsyncQueryTokenException),
|
|
):
|
|
cancellable_manager.cancel_job("job-1", "other-chan", 7)
|
|
|
|
celery_app.control.revoke.assert_not_called()
|
|
|
|
|
|
def test_cancel_job_unknown_raises(cancellable_manager):
|
|
cancellable_manager._cache.get.return_value = None
|
|
|
|
with (
|
|
mock.patch("superset.extensions.celery_app") as celery_app,
|
|
raises(AsyncQueryJobException),
|
|
):
|
|
cancellable_manager.cancel_job("job-1", "chan-1", 7)
|
|
|
|
celery_app.control.revoke.assert_not_called()
|
|
|
|
|
|
def test_is_job_cancelled(cancellable_manager):
|
|
cancellable_manager._cache.get.return_value = json.dumps(
|
|
{"channel_id": "chan-1", "user_id": 7, "cancelled": True}
|
|
)
|
|
assert cancellable_manager.is_job_cancelled("job-1") is True
|
|
|
|
cancellable_manager._cache.get.return_value = json.dumps(
|
|
{"channel_id": "chan-1", "user_id": 7}
|
|
)
|
|
assert cancellable_manager.is_job_cancelled("job-1") is False
|
|
|
|
cancellable_manager._cache.get.return_value = None
|
|
assert cancellable_manager.is_job_cancelled("job-1") is False
|
|
|
|
|
|
def test_is_job_cancelled_swallows_cache_errors(cancellable_manager):
|
|
"""A cache failure must not escape and mask the worker's original error."""
|
|
cancellable_manager._cache.get.side_effect = RuntimeError("redis down")
|
|
assert cancellable_manager.is_job_cancelled("job-1") is False
|
|
|
|
|
|
def test_update_job_clears_registry_before_terminal_event(cancellable_manager):
|
|
"""Clearing first is what makes a cancel that lost the race a 404."""
|
|
calls = []
|
|
cancellable_manager._stream_limit = 100
|
|
cancellable_manager._stream_limit_firehose = 1000
|
|
cancellable_manager._cache.delete.side_effect = lambda *_: calls.append("delete")
|
|
cancellable_manager._cache.xadd.side_effect = lambda *_: calls.append("xadd")
|
|
job_metadata = {"channel_id": "chan-1", "job_id": "job-1", "user_id": 7}
|
|
|
|
cancellable_manager.update_job(job_metadata, AsyncQueryManager.STATUS_DONE)
|
|
|
|
cancellable_manager._cache.delete.assert_called_once_with(
|
|
"async-events-job-cancel:job-1"
|
|
)
|
|
assert calls == ["delete", "xadd", "xadd"]
|