mirror of
https://github.com/apache/superset.git
synced 2026-06-23 08:29:18 +00:00
Compare commits
4 Commits
fix/issue-
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f9b0c8305 | ||
|
|
5916ec4876 | ||
|
|
36781fbf47 | ||
|
|
215b207ae4 |
2
.github/workflows/generate-FOSSA-report.yml
vendored
2
.github/workflows/generate-FOSSA-report.yml
vendored
@@ -37,7 +37,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "11"
|
||||
|
||||
2
.github/workflows/license-check.yml
vendored
2
.github/workflows/license-check.yml
vendored
@@ -23,7 +23,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "11"
|
||||
|
||||
2
.github/workflows/superset-docs-deploy.yml
vendored
2
.github/workflows/superset-docs-deploy.yml
vendored
@@ -71,7 +71,7 @@ jobs:
|
||||
node-version-file: "./docs/.nvmrc"
|
||||
- name: Setup Python
|
||||
uses: ./.github/actions/setup-backend/
|
||||
- uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
- uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
|
||||
with:
|
||||
distribution: "zulu"
|
||||
java-version: "21"
|
||||
|
||||
@@ -396,3 +396,102 @@ test('does not emit cross-filter when no dimensions and time-based X-axis', asyn
|
||||
expect(setDataMaskMock).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
// Test for issue #41102: horizontal bar cross-filter must use the category
|
||||
// value, not the metric. For horizontal bars the data tuple is value-first
|
||||
// (e.g. [100, 'Product A']), so relying on data[0] emitted the metric value.
|
||||
test('emits cross-filter on the category value for a horizontal categorical bar', async () => {
|
||||
const setDataMaskMock = jest.fn();
|
||||
|
||||
const propsWithHorizontalXAxis: TimeseriesChartTransformedProps = {
|
||||
...defaultProps,
|
||||
emitCrossFilters: true,
|
||||
setDataMask: setDataMaskMock,
|
||||
groupby: [], // No dimensions
|
||||
xAxis: {
|
||||
label: 'category_column',
|
||||
type: AxisType.Category, // Categorical X-axis
|
||||
},
|
||||
};
|
||||
|
||||
render(<EchartsTimeseries {...propsWithHorizontalXAxis} />);
|
||||
|
||||
const lastCall = mockEchart.mock.calls.at(-1);
|
||||
expect(lastCall).toBeDefined();
|
||||
const [props] = lastCall as [EchartsProps];
|
||||
|
||||
const clickHandler = props.eventHandlers?.click;
|
||||
if (clickHandler) {
|
||||
clickHandler({
|
||||
seriesName: 'Sales', // This is the metric name
|
||||
data: [100, 'Product A'], // Horizontal: value first, category second
|
||||
name: 'Product A',
|
||||
dataIndex: 0,
|
||||
});
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(setDataMaskMock).toHaveBeenCalled();
|
||||
},
|
||||
{ timeout: 500 },
|
||||
);
|
||||
|
||||
// Must filter on the category ('Product A'), not the metric value (100)
|
||||
const dataMaskCall = setDataMaskMock.mock.calls[0][0];
|
||||
expect(dataMaskCall.extraFormData.filters).toEqual([
|
||||
{
|
||||
col: 'category_column',
|
||||
op: 'IN',
|
||||
val: ['Product A'],
|
||||
},
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
// Test for issue #41102: the context-menu ("Add cross-filter") path must also
|
||||
// use the category value, not the metric, for a horizontal categorical bar.
|
||||
test('context menu cross-filter uses the category value for a horizontal categorical bar', async () => {
|
||||
const onContextMenuMock = jest.fn();
|
||||
|
||||
const propsWithHorizontalXAxis: TimeseriesChartTransformedProps = {
|
||||
...defaultProps,
|
||||
emitCrossFilters: true,
|
||||
onContextMenu: onContextMenuMock,
|
||||
groupby: [], // No dimensions
|
||||
xAxis: {
|
||||
label: 'category_column',
|
||||
type: AxisType.Category, // Categorical X-axis
|
||||
},
|
||||
};
|
||||
|
||||
render(<EchartsTimeseries {...propsWithHorizontalXAxis} />);
|
||||
|
||||
const lastCall = mockEchart.mock.calls.at(-1);
|
||||
expect(lastCall).toBeDefined();
|
||||
const [props] = lastCall as [EchartsProps];
|
||||
|
||||
const contextMenuHandler = props.eventHandlers?.contextmenu;
|
||||
expect(contextMenuHandler).toBeDefined();
|
||||
if (contextMenuHandler) {
|
||||
await contextMenuHandler({
|
||||
seriesName: 'Sales', // This is the metric name
|
||||
data: [100, 'Product A'], // Horizontal: value first, category second
|
||||
name: 'Product A',
|
||||
event: { stop: jest.fn(), event: { clientX: 10, clientY: 20 } },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onContextMenuMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// The cross-filter must use the category ('Product A'), not the metric (100)
|
||||
const { crossFilter } = onContextMenuMock.mock.calls[0][2];
|
||||
expect(crossFilter.dataMask.extraFormData.filters).toEqual([
|
||||
{
|
||||
col: 'category_column',
|
||||
op: 'IN',
|
||||
val: ['Product A'],
|
||||
},
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -234,9 +234,12 @@ export default function EchartsTimeseries({
|
||||
// Cross-filter by dimension (original behavior)
|
||||
const { seriesName: name } = props;
|
||||
handleChange(name);
|
||||
} else if (canCrossFilterByXAxis && props.data?.[0] != null) {
|
||||
// Cross-filter by X-axis value when no dimensions (issue #25334)
|
||||
handleXAxisChange(props.data[0]);
|
||||
} else if (canCrossFilterByXAxis && props.name != null) {
|
||||
// Cross-filter by X-axis value when no dimensions (issue #25334).
|
||||
// Use `name` (the category-axis value) instead of `data[0]`: for
|
||||
// horizontal bars the data tuple is value-first, so `data[0]` would
|
||||
// be the metric value rather than the category (issue #41102).
|
||||
handleXAxisChange(props.name);
|
||||
}
|
||||
}, TIMER_DURATION);
|
||||
},
|
||||
@@ -318,8 +321,10 @@ export default function EchartsTimeseries({
|
||||
let crossFilter;
|
||||
if (hasDimensions) {
|
||||
crossFilter = getCrossFilterDataMask(seriesName);
|
||||
} else if (canCrossFilterByXAxis && data?.[0] != null) {
|
||||
crossFilter = getXAxisCrossFilterDataMask(data[0]);
|
||||
} else if (canCrossFilterByXAxis && eventParams.name != null) {
|
||||
// Use `name` (the category-axis value), not `data[0]`, so horizontal
|
||||
// bars cross-filter on the category and not the metric (issue #41102).
|
||||
crossFilter = getXAxisCrossFilterDataMask(eventParams.name);
|
||||
}
|
||||
|
||||
onContextMenu(pointerEvent.clientX, pointerEvent.clientY, {
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
# 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.
|
||||
"""clean up stale can_import permission on ImportExportRestApi
|
||||
|
||||
The role-edit screen shows "can import on ImportExportRestApi" twice. Only one
|
||||
of those is real: the live permission is ``can_import_`` (note the trailing
|
||||
underscore), derived from the ``import_`` method on ``ImportExportRestApi``.
|
||||
The visual duplicate is a stale ``can_import`` (no trailing underscore)
|
||||
permission-view-menu (PVM) for the ``ImportExportRestApi`` view menu, left over
|
||||
from an older Superset/FAB era and persisted across upgrades. FAB renders
|
||||
``can_import_`` as "can import " and ``can_import`` as "can import", which look
|
||||
identical in the UI.
|
||||
|
||||
Current code can no longer create the stale row, so this migration removes it.
|
||||
Before deleting, any role that holds the stale PVM is given the live
|
||||
``can_import_`` PVM so no role silently loses import access.
|
||||
|
||||
This is scoped to the ``ImportExportRestApi`` view menu ONLY. It does not touch
|
||||
the ``can_import`` permission name globally; ``migrate_roles`` only deletes the
|
||||
``can_import`` permission row if it has become a true orphan (no remaining
|
||||
PVMs reference it).
|
||||
|
||||
Revision ID: a7d3f1b9c2e4
|
||||
Revises: 78a40c08b4be
|
||||
Create Date: 2026-06-23 03:23:55.000000
|
||||
|
||||
"""
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "a7d3f1b9c2e4"
|
||||
down_revision = "78a40c08b4be"
|
||||
|
||||
from alembic import op # noqa: E402
|
||||
from sqlalchemy.exc import SQLAlchemyError # noqa: E402
|
||||
from sqlalchemy.orm import Session # noqa: E402
|
||||
|
||||
from superset.migrations.shared.security_converge import ( # noqa: E402
|
||||
add_pvms,
|
||||
migrate_roles,
|
||||
Pvm,
|
||||
)
|
||||
|
||||
VIEW_MENU = "ImportExportRestApi"
|
||||
|
||||
# The live permission that should exist for the import endpoint. We ensure it is
|
||||
# present (it normally is, created on startup by FAB) before reassigning roles to
|
||||
# it, so the lookup in ``migrate_roles`` cannot resolve to ``None``.
|
||||
NEW_PVMS = {VIEW_MENU: ("can_import_",)}
|
||||
|
||||
# Map the stale PVM (``can_import`` with NO trailing underscore) to the live one
|
||||
# (``can_import_``). ``migrate_roles`` will, for every role holding the stale
|
||||
# PVM: add the live PVM (if missing), remove the stale PVM, then delete the
|
||||
# stale PVM row. The stale ``can_import`` permission row and the view menu are
|
||||
# only deleted by the helper if they become orphans afterwards.
|
||||
PVM_MAP = {
|
||||
Pvm(VIEW_MENU, "can_import"): (Pvm(VIEW_MENU, "can_import_"),),
|
||||
}
|
||||
|
||||
|
||||
def do_upgrade(session: Session) -> None:
|
||||
"""Ensure the live ``can_import_`` PVM exists and migrate any role holding the
|
||||
stale ``can_import`` PVM onto it before the stale row is removed."""
|
||||
# Guarantee the live PVM exists before we point roles at it. ``add_pvms`` is
|
||||
# idempotent: it only creates rows that are missing.
|
||||
add_pvms(session, NEW_PVMS)
|
||||
# On a clean install the stale PVM does not exist; ``migrate_roles`` resolves
|
||||
# the old PVM to ``None`` and becomes a no-op, so this is safe to run
|
||||
# everywhere.
|
||||
migrate_roles(session, PVM_MAP)
|
||||
|
||||
|
||||
def do_downgrade(session: Session) -> None:
|
||||
"""Intentionally a no-op: the upgrade only removes a stale duplicate PVM and
|
||||
leaves the live ``can_import_`` permission untouched, so there is no prior
|
||||
state worth restoring (recreating the stale row would just reintroduce the
|
||||
duplicate)."""
|
||||
# No-op by design. Recreating the stale ``can_import`` PVM and moving roles
|
||||
# back onto it would reintroduce the very duplicate permission this
|
||||
# migration removes (and the orphaned row serves no purpose). The live
|
||||
# ``can_import_`` permission is unaffected by the upgrade, so there is
|
||||
# nothing meaningful to restore.
|
||||
pass
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
session = Session(bind=bind)
|
||||
do_upgrade(session)
|
||||
try:
|
||||
session.commit()
|
||||
except SQLAlchemyError as ex:
|
||||
session.rollback()
|
||||
raise Exception(f"An error occurred while upgrading permissions: {ex}") from ex
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
session = Session(bind=bind)
|
||||
do_downgrade(session)
|
||||
try:
|
||||
session.commit()
|
||||
except SQLAlchemyError as ex:
|
||||
session.rollback()
|
||||
raise Exception(
|
||||
f"An error occurred while downgrading permissions: {ex}"
|
||||
) from ex
|
||||
@@ -1289,9 +1289,13 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
|
||||
:returns: The error message
|
||||
"""
|
||||
|
||||
quoted_tables = [f"`{table}`" for table in tables]
|
||||
return f"""You need access to the following tables: {", ".join(quoted_tables)},
|
||||
`all_database_access` or `all_datasource_access` permission"""
|
||||
quoted_tables = [f'"{table}"' for table in tables]
|
||||
return _(
|
||||
"You need access to the following tables: %(tables)s, "
|
||||
"'all_database_access' or 'all_datasource_access' permission"
|
||||
) % {
|
||||
"tables": ",".join(quoted_tables),
|
||||
}
|
||||
|
||||
def get_table_access_error_object(self, tables: set["Table"]) -> SupersetError:
|
||||
"""
|
||||
|
||||
@@ -1123,14 +1123,17 @@ class SQLStatement(BaseSQLStatement[exp.Expression]):
|
||||
"""
|
||||
Check if the statement has a subquery.
|
||||
|
||||
Covers explicit subqueries, set operations (``UNION``/``INTERSECT``/
|
||||
``EXCEPT``), and any nested ``SELECT`` regardless of the top-level node
|
||||
type (e.g. when wrapped in parentheses or a set operation).
|
||||
|
||||
:return: True if the statement has a subquery.
|
||||
"""
|
||||
return bool(self._parsed.find(exp.Subquery)) or (
|
||||
isinstance(self._parsed, exp.Select)
|
||||
and any(
|
||||
isinstance(expression, exp.Select)
|
||||
for expression in self._parsed.walk()
|
||||
if expression != self._parsed
|
||||
return (
|
||||
self.is_set_operation()
|
||||
or bool(self._parsed.find(exp.Subquery))
|
||||
or any(
|
||||
select != self._parsed for select in self._parsed.find_all(exp.Select)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
# 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 importlib import import_module
|
||||
|
||||
import pytest
|
||||
|
||||
from superset import db
|
||||
from superset.migrations.shared.security_converge import (
|
||||
_add_permission,
|
||||
_add_permission_view,
|
||||
_add_view_menu,
|
||||
_find_pvm,
|
||||
Role,
|
||||
)
|
||||
|
||||
migration_module = import_module(
|
||||
"superset.migrations.versions."
|
||||
"2026-06-23_03-23_a7d3f1b9c2e4_cleanup_stale_can_import_pvm"
|
||||
)
|
||||
|
||||
upgrade = migration_module.do_upgrade
|
||||
|
||||
VIEW = "ImportExportRestApi"
|
||||
STALE_PERM = "can_import" # no trailing underscore
|
||||
LIVE_PERM = "can_import_" # trailing underscore (the real permission)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("app_context")
|
||||
def test_migration_reassigns_roles_and_removes_stale_pvm() -> None:
|
||||
# Arrange: simulate a metadata DB upgraded from an older era that still has
|
||||
# the stale ``can_import`` PVM on the ImportExportRestApi view menu, held by
|
||||
# a role.
|
||||
view_menu = _add_view_menu(db.session, VIEW)
|
||||
stale_permission = _add_permission(db.session, STALE_PERM)
|
||||
stale_pvm = _add_permission_view(db.session, stale_permission, view_menu)
|
||||
|
||||
role = Role(name="stale_import_role")
|
||||
role.permissions.append(stale_pvm)
|
||||
db.session.add(role)
|
||||
db.session.commit()
|
||||
|
||||
assert _find_pvm(db.session, VIEW, STALE_PERM) is not None
|
||||
|
||||
# Act
|
||||
upgrade(db.session)
|
||||
|
||||
# Assert: the live PVM exists, the stale one is gone, and the role kept
|
||||
# import access by being moved onto the live PVM.
|
||||
live_pvm = _find_pvm(db.session, VIEW, LIVE_PERM)
|
||||
assert live_pvm is not None
|
||||
assert _find_pvm(db.session, VIEW, STALE_PERM) is None
|
||||
|
||||
refreshed_role = db.session.query(Role).filter_by(name="stale_import_role").one()
|
||||
assert live_pvm in refreshed_role.permissions
|
||||
|
||||
# Cleanup
|
||||
db.session.delete(refreshed_role)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("app_context")
|
||||
def test_migration_is_noop_on_clean_install() -> None:
|
||||
# No stale PVM present (clean install). The migration must not error and must
|
||||
# leave the live permission intact.
|
||||
assert _find_pvm(db.session, VIEW, STALE_PERM) is None
|
||||
|
||||
upgrade(db.session)
|
||||
|
||||
assert _find_pvm(db.session, VIEW, STALE_PERM) is None
|
||||
assert _find_pvm(db.session, VIEW, LIVE_PERM) is not None
|
||||
@@ -402,8 +402,8 @@ def test_raise_for_access_query_default_schema(
|
||||
)
|
||||
assert (
|
||||
str(excinfo.value)
|
||||
== """You need access to the following tables: `public.ab_user`,
|
||||
`all_database_access` or `all_datasource_access` permission"""
|
||||
== 'You need access to the following tables: "public.ab_user", '
|
||||
"'all_database_access' or 'all_datasource_access' permission"
|
||||
)
|
||||
|
||||
|
||||
@@ -1454,8 +1454,8 @@ def test_raise_for_access_catalog(
|
||||
sm.raise_for_access(query=query)
|
||||
assert (
|
||||
str(excinfo.value)
|
||||
== """You need access to the following tables: `db1.public.ab_user`,
|
||||
`all_database_access` or `all_datasource_access` permission"""
|
||||
== 'You need access to the following tables: "db1.public.ab_user", '
|
||||
"'all_database_access' or 'all_datasource_access' permission"
|
||||
)
|
||||
|
||||
query.sql = "SELECT * FROM db2.public.ab_user"
|
||||
@@ -1463,8 +1463,8 @@ def test_raise_for_access_catalog(
|
||||
sm.raise_for_access(query=query)
|
||||
assert (
|
||||
str(excinfo.value)
|
||||
== """You need access to the following tables: `db2.public.ab_user`,
|
||||
`all_database_access` or `all_datasource_access` permission"""
|
||||
== 'You need access to the following tables: "db2.public.ab_user", '
|
||||
"'all_database_access' or 'all_datasource_access' permission"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -3549,6 +3549,17 @@ def test_tokenize_kql(kql: str, expected: list[tuple[KQLTokenType, str]]) -> Non
|
||||
"postgresql",
|
||||
True,
|
||||
),
|
||||
# Set operations: a top-level UNION/INTERSECT/EXCEPT is not an
|
||||
# exp.Subquery, so it must be detected explicitly. A predicate fragment
|
||||
# that introduces one (e.g. supplied through a chart filter) must be
|
||||
# flagged.
|
||||
("true UNION SELECT name FROM other_table", "postgresql", True),
|
||||
("1 = 1 UNION ALL SELECT password FROM users", "postgresql", True),
|
||||
("SELECT 1 INTERSECT SELECT 2", "postgresql", True),
|
||||
("SELECT 1 EXCEPT SELECT 2", "postgresql", True),
|
||||
# Nested SELECT under non-Select top-level nodes (e.g. extra
|
||||
# parentheses) must still be detected.
|
||||
("name IN (((SELECT secret FROM s)))", "postgresql", True),
|
||||
],
|
||||
)
|
||||
def test_has_subquery(sql: str, engine: str, expected: bool) -> None:
|
||||
|
||||
Reference in New Issue
Block a user