mirror of
https://github.com/apache/superset.git
synced 2026-06-11 18:49:15 +00:00
243 lines
8.9 KiB
Python
243 lines
8.9 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 typing import Any, cast
|
|
|
|
from flask import current_app
|
|
from pytest_mock import MockerFixture
|
|
|
|
from superset.extensions import appbuilder
|
|
from superset.security.guest_token import (
|
|
GUEST_TOKEN_REVOCATION_CLAIM,
|
|
GuestTokenResources,
|
|
GuestTokenResourceType,
|
|
GuestTokenRlsRule,
|
|
GuestTokenUser,
|
|
)
|
|
from superset.security.manager import SupersetSecurityManager
|
|
|
|
USER: GuestTokenUser = {"username": "guest"}
|
|
RESOURCES: GuestTokenResources = [
|
|
{"type": GuestTokenResourceType.DASHBOARD, "id": "some-uuid"}
|
|
]
|
|
RLS: list[GuestTokenRlsRule] = []
|
|
|
|
|
|
def _decode(sm: SupersetSecurityManager, raw_token: bytes) -> dict[str, Any]:
|
|
# PyJWT returns a str at runtime, though the helper is typed as bytes.
|
|
return sm.parse_jwt_guest_token(cast(str, raw_token))
|
|
|
|
|
|
def test_minted_token_carries_revocation_claim_when_enabled(
|
|
mocker: MockerFixture, app_context: None
|
|
) -> None:
|
|
"""A token minted with revocation enabled carries the current version."""
|
|
sm = SupersetSecurityManager(appbuilder)
|
|
current_app.config["GUEST_TOKEN_REVOCATION_ENABLED"] = True
|
|
mocker.patch(
|
|
"superset.security.manager.get_current_guest_token_revocation_version",
|
|
return_value=3,
|
|
)
|
|
|
|
raw_token = sm.create_guest_access_token(USER, RESOURCES, RLS)
|
|
decoded = _decode(sm, raw_token)
|
|
assert decoded[GUEST_TOKEN_REVOCATION_CLAIM] == 3
|
|
|
|
|
|
def test_minted_token_uses_default_version_when_disabled(
|
|
mocker: MockerFixture, app_context: None
|
|
) -> None:
|
|
"""When revocation is disabled, the metadata store is not consulted."""
|
|
sm = SupersetSecurityManager(appbuilder)
|
|
current_app.config["GUEST_TOKEN_REVOCATION_ENABLED"] = False
|
|
read_version = mocker.patch(
|
|
"superset.security.manager.get_current_guest_token_revocation_version",
|
|
)
|
|
|
|
raw_token = sm.create_guest_access_token(USER, RESOURCES, RLS)
|
|
decoded = _decode(sm, raw_token)
|
|
assert decoded[GUEST_TOKEN_REVOCATION_CLAIM] == 0
|
|
read_version.assert_not_called()
|
|
|
|
|
|
def test_token_not_revoked_when_feature_disabled(
|
|
mocker: MockerFixture, app_context: None
|
|
) -> None:
|
|
"""With the feature off, even an old-versioned token is never revoked."""
|
|
sm = SupersetSecurityManager(appbuilder)
|
|
current_app.config["GUEST_TOKEN_REVOCATION_ENABLED"] = False
|
|
# Even if the stored version is high, disabled => never revoked.
|
|
mocker.patch(
|
|
"superset.security.manager.get_current_guest_token_revocation_version",
|
|
return_value=99,
|
|
)
|
|
assert sm._is_guest_token_revoked({GUEST_TOKEN_REVOCATION_CLAIM: 0}) is False
|
|
|
|
|
|
def test_legacy_token_without_claim_valid_by_default(
|
|
mocker: MockerFixture, app_context: None
|
|
) -> None:
|
|
"""A pre-feature token (no claim) is valid until version is bumped above 0."""
|
|
sm = SupersetSecurityManager(appbuilder)
|
|
current_app.config["GUEST_TOKEN_REVOCATION_ENABLED"] = True
|
|
mocker.patch(
|
|
"superset.security.manager.get_current_guest_token_revocation_version",
|
|
return_value=0,
|
|
)
|
|
# No revocation claim at all (legacy token).
|
|
assert sm._is_guest_token_revoked({"user": USER}) is False
|
|
|
|
|
|
def test_legacy_token_revoked_after_bump(
|
|
mocker: MockerFixture, app_context: None
|
|
) -> None:
|
|
"""Once the expected version is bumped, legacy (v0) tokens are rejected."""
|
|
sm = SupersetSecurityManager(appbuilder)
|
|
current_app.config["GUEST_TOKEN_REVOCATION_ENABLED"] = True
|
|
mocker.patch(
|
|
"superset.security.manager.get_current_guest_token_revocation_version",
|
|
return_value=1,
|
|
)
|
|
assert sm._is_guest_token_revoked({"user": USER}) is True
|
|
|
|
|
|
def test_current_versioned_token_not_revoked(
|
|
mocker: MockerFixture, app_context: None
|
|
) -> None:
|
|
"""A token minted at the current version is accepted."""
|
|
sm = SupersetSecurityManager(appbuilder)
|
|
current_app.config["GUEST_TOKEN_REVOCATION_ENABLED"] = True
|
|
mocker.patch(
|
|
"superset.security.manager.get_current_guest_token_revocation_version",
|
|
return_value=2,
|
|
)
|
|
assert sm._is_guest_token_revoked({GUEST_TOKEN_REVOCATION_CLAIM: 2}) is False
|
|
assert sm._is_guest_token_revoked({GUEST_TOKEN_REVOCATION_CLAIM: 3}) is False
|
|
|
|
|
|
def test_malformed_claim_treated_as_default(
|
|
mocker: MockerFixture, app_context: None
|
|
) -> None:
|
|
"""A non-integer claim falls back to the default version (0)."""
|
|
sm = SupersetSecurityManager(appbuilder)
|
|
current_app.config["GUEST_TOKEN_REVOCATION_ENABLED"] = True
|
|
mocker.patch(
|
|
"superset.security.manager.get_current_guest_token_revocation_version",
|
|
return_value=1,
|
|
)
|
|
assert sm._is_guest_token_revoked({GUEST_TOKEN_REVOCATION_CLAIM: "oops"}) is True
|
|
|
|
|
|
def test_bump_increments_stored_version(mocker: MockerFixture) -> None:
|
|
"""bump_guest_token_revocation_version reads, increments and persists."""
|
|
# pylint: disable=import-outside-toplevel
|
|
import superset.security.guest_token as gt
|
|
|
|
mocker.patch.object(
|
|
gt, "get_current_guest_token_revocation_version", return_value=4
|
|
)
|
|
upsert = mocker.patch("superset.key_value.shared_entries.upsert_shared_value")
|
|
|
|
new_version = gt.bump_guest_token_revocation_version()
|
|
|
|
assert new_version == 5
|
|
upsert.assert_called_once()
|
|
args = upsert.call_args.args
|
|
assert args[1] == 5
|
|
|
|
|
|
def test_get_version_defaults_when_missing(mocker: MockerFixture) -> None:
|
|
"""A missing metadata entry yields the default version 0."""
|
|
# pylint: disable=import-outside-toplevel
|
|
import superset.security.guest_token as gt
|
|
|
|
mocker.patch(
|
|
"superset.key_value.shared_entries.get_shared_value", return_value=None
|
|
)
|
|
assert gt.get_current_guest_token_revocation_version() == 0
|
|
|
|
|
|
def test_get_version_falls_back_on_store_error(mocker: MockerFixture) -> None:
|
|
"""A metadata-store read failure falls back to the default version (0).
|
|
|
|
This exercises the broad-except branch that keeps a transient store outage
|
|
from hard-failing every guest-token validation (fail-open to the
|
|
pre-feature behaviour).
|
|
"""
|
|
# pylint: disable=import-outside-toplevel
|
|
import superset.security.guest_token as gt
|
|
|
|
mocker.patch(
|
|
"superset.key_value.shared_entries.get_shared_value",
|
|
side_effect=RuntimeError("metadata store unavailable"),
|
|
)
|
|
assert gt.get_current_guest_token_revocation_version() == 0
|
|
|
|
|
|
def test_get_version_falls_back_on_malformed_stored_value(
|
|
mocker: MockerFixture,
|
|
) -> None:
|
|
"""A non-integer stored value falls back to the default version (0)."""
|
|
# pylint: disable=import-outside-toplevel
|
|
import superset.security.guest_token as gt
|
|
|
|
mocker.patch(
|
|
"superset.key_value.shared_entries.get_shared_value",
|
|
return_value="not-an-int",
|
|
)
|
|
assert gt.get_current_guest_token_revocation_version() == 0
|
|
|
|
|
|
def test_revoked_token_rejected_through_request_flow(
|
|
mocker: MockerFixture, app_context: None
|
|
) -> None:
|
|
"""The full request->parse->revocation flow rejects a revoked token.
|
|
|
|
``get_guest_user_from_request`` must return ``None`` (so the login manager
|
|
issues a 401) when a parsed, otherwise-valid token has been revoked by a
|
|
version bump. A current-version token still resolves to a guest user.
|
|
"""
|
|
sm = SupersetSecurityManager(appbuilder)
|
|
current_app.config["GUEST_TOKEN_REVOCATION_ENABLED"] = True
|
|
|
|
# Mint a token stamped with version 1.
|
|
mocker.patch(
|
|
"superset.security.manager.get_current_guest_token_revocation_version",
|
|
return_value=1,
|
|
)
|
|
raw_token = sm.create_guest_access_token(USER, RESOURCES, RLS)
|
|
|
|
header_name = current_app.config["GUEST_TOKEN_HEADER_NAME"]
|
|
request = mocker.MagicMock()
|
|
request.headers = {header_name: raw_token}
|
|
request.form = {}
|
|
|
|
# Expected version has been bumped above the token's version => revoked.
|
|
mocker.patch(
|
|
"superset.security.manager.get_current_guest_token_revocation_version",
|
|
return_value=2,
|
|
)
|
|
assert sm.get_guest_user_from_request(request) is None
|
|
|
|
# When the expected version matches, the same token resolves to a user.
|
|
mocker.patch(
|
|
"superset.security.manager.get_current_guest_token_revocation_version",
|
|
return_value=1,
|
|
)
|
|
guest_user = sm.get_guest_user_from_request(request)
|
|
assert guest_user is not None
|
|
assert guest_user.is_guest_user is True
|