Compare commits

..

5 Commits

Author SHA1 Message Date
Amin Ghadersohi
b652b996a4 chore(deps): raise SQLAlchemy lower bound to >=1.4.43
The python-oracledb (oracle+oracledb) dialect that the Oracle engine
spec now relies on was added in SQLAlchemy 1.4.43. The old `>=1.4`
floor allowed 1.4.0-1.4.42, where that dialect does not exist, so a
downstream resolver could install a SQLAlchemy that cannot connect to
Oracle. The lockfile already resolves to 1.4.54, so this is a no-op for
the pinned environment and only constrains library installs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 02:47:45 -07:00
Greg Neighbors
3818152191 fix(mcp): user/role tools demanded a permission FAB never registers (#41858)
Co-authored-by: Greg Neighbors <gregneighbors@Gregs-Air-2.lan>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 17:36:54 -07:00
Mike Bridge
4dde4d2c70 fix(versioning): make baseline capture atomic (#41941)
Co-authored-by: Mike Bridge <michael.bridge@ext.preset.io>
2026-07-10 16:12:32 -07:00
Yinka Metrics
35712ff977 fix(sqllab): show truncated table list warning (#41617)
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-07-10 16:10:20 -07:00
dependabot[bot]
bc80d138fd chore(deps): bump slack-sdk from 3.42.0 to 3.43.0 (#41893)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-10 14:59:10 -07:00
26 changed files with 390 additions and 571 deletions

View File

@@ -106,8 +106,8 @@ dependencies = [
"shillelagh[gsheetsapi]>=1.4.4, <2.0",
"sshtunnel>=0.4.0, <0.5",
"simplejson>=4.1.1",
"slack_sdk>=3.42.0, <4",
"sqlalchemy>=1.4, <2",
"slack_sdk>=3.43.0, <4",
"sqlalchemy>=1.4.43, <2", # 1.4.43 adds the python-oracledb (oracle+oracledb) dialect
"sqlalchemy-continuum>=1.6.0, <2.0.0",
"sqlalchemy-utils>=0.42.1, <0.43", # expanding lowerbound to work with pydoris
"sqlglot>=30.8.0, <31",

View File

@@ -390,7 +390,7 @@ six==1.17.0
# python-dateutil
# rfc3339-validator
# wtforms-json
slack-sdk==3.42.0
slack-sdk==3.43.0
# via apache-superset (pyproject.toml)
sniffio==1.3.1
# via trio

View File

@@ -959,7 +959,7 @@ six==1.17.0
# python-dateutil
# rfc3339-validator
# wtforms-json
slack-sdk==3.42.0
slack-sdk==3.43.0
# via
# -c requirements/base-constraint.txt
# apache-superset

View File

@@ -42,9 +42,9 @@ const createProps = (props = {}) => ({
...props,
});
const getTableMockFunction = () =>
const getTableMockFunction = (count = 4) =>
({
count: 4,
count,
result: [
{ label: 'table_a', value: 'table_a' },
{ label: 'table_b', value: 'table_b' },
@@ -82,6 +82,35 @@ afterEach(async () => {
await new Promise(resolve => setTimeout(resolve, 0));
});
test('shows a helper message when some tables are not shown', async () => {
fetchMock.get(catalogApiRoute, { result: [] });
fetchMock.get(schemaApiRoute, { result: ['test_schema'] });
fetchMock.get(tablesApiRoute, getTableMockFunction(5));
const props = createProps();
render(<TableSelector {...props} />, { useRedux: true, store });
expect(
await screen.findByText('Some tables are not shown. Refine your search.'),
).toBeInTheDocument();
});
test('does not show the helper message when table search is read-only', async () => {
fetchMock.get(catalogApiRoute, { result: [] });
fetchMock.get(schemaApiRoute, { result: ['test_schema'] });
fetchMock.get(tablesApiRoute, getTableMockFunction(5));
const props = createProps({ readOnly: true });
render(<TableSelector {...props} />, { useRedux: true, store });
await waitFor(() => {
expect(fetchMock.callHistory.called(tablesApiRoute)).toBe(true);
});
expect(
screen.queryByText('Some tables are not shown. Refine your search.'),
).not.toBeInTheDocument();
});
test('renders with default props', async () => {
fetchMock.get(catalogApiRoute, { result: [] });
fetchMock.get(schemaApiRoute, { result: [] });
@@ -205,10 +234,10 @@ test('table select retain value if not in SQL Lab mode', async () => {
);
}, 15000);
test('renders disabled without schema', async () => {
test('renders disabled without schema or helper message', async () => {
fetchMock.get(catalogApiRoute, { result: [] });
fetchMock.get(schemaApiRoute, { result: [] });
fetchMock.get(tablesApiRoute, getTableMockFunction());
fetchMock.get(tablesApiRoute, getTableMockFunction(5));
const props = createProps();
render(<TableSelector {...props} schema={undefined} />, {
@@ -221,6 +250,9 @@ test('renders disabled without schema', async () => {
await waitFor(() => {
expect(tableSelect).toBeDisabled();
});
expect(
screen.queryByText('Some tables are not shown. Refine your search.'),
).not.toBeInTheDocument();
});
test('table multi select retain all the values selected', async () => {

View File

@@ -221,6 +221,7 @@ const TableSelector: FunctionComponent<TableSelectorProps> = ({
: [],
[data, customTableOptionLabelRenderer],
);
const hasMoreTables = data?.hasMore;
useEffect(() => {
// reset selections
@@ -339,6 +340,11 @@ const TableSelector: FunctionComponent<TableSelectorProps> = ({
<>
<StyledFormLabel>{label}</StyledFormLabel>
{renderSelectRow(select, refreshLabel)}
{hasMoreTables && !disabled && (
<div className="table-length">
{t('Some tables are not shown. Refine your search.')}
</div>
)}
</>
);
}

View File

@@ -17,8 +17,6 @@
* under the License.
*/
import type { LanguagePack } from '@apache-superset/core/translation';
const mockConfigure = jest.fn();
const mockInitFeatureFlags = jest.fn();
const mockMakeApi = jest.fn(() => jest.fn());
@@ -113,83 +111,3 @@ test('falls back to en when passing locale to setupFormatters', async () => {
expect(mockSetupFormatters).toHaveBeenCalledWith({}, {}, 'en');
});
// --- language pack loading semantics (issues #35330, PR #41780) -----------
// English: nothing is loaded at all. Non-English: the pack is expected to
// already be on window (set by the versioned classic <script> tag spa.html
// emits before the entry bundle), so configuration is synchronous and no
// English flash can occur. The async fetch is a fallback only, and it only
// ever requests the selected locale.
const FAKE_PACK: LanguagePack = {
domain: 'superset',
locale_data: {
superset: {
'': {
domain: 'superset',
lang: 'fr',
plural_forms: 'nplurals=2; plural=(n > 1);',
},
},
},
};
afterEach(() => {
delete window.__SUPERSET_LANGUAGE_PACK__;
});
test('configures synchronously from the window pack without fetching', async () => {
mockGetBootstrapData.mockReturnValue(bootstrapData('fr'));
window.__SUPERSET_LANGUAGE_PACK__ = FAKE_PACK;
const fetchSpy = jest.spyOn(global, 'fetch');
await runPreamble();
expect(mockConfigure).toHaveBeenCalledWith({ languagePack: FAKE_PACK });
expect(fetchSpy).not.toHaveBeenCalled();
expect(mockDayjsLocale).toHaveBeenCalledWith('fr');
fetchSpy.mockRestore();
});
test('prefers an operator-supplied bootstrap pack over the window pack', async () => {
const overridePack = { ...FAKE_PACK, domain: 'override' };
const data = bootstrapData('fr');
(data.common as Record<string, unknown>).language_pack = overridePack;
mockGetBootstrapData.mockReturnValue(data);
window.__SUPERSET_LANGUAGE_PACK__ = FAKE_PACK;
await runPreamble();
expect(mockConfigure).toHaveBeenCalledWith({ languagePack: overridePack });
});
test('loads no language pack at all for English', async () => {
mockGetBootstrapData.mockReturnValue(bootstrapData('en'));
const fetchSpy = jest.spyOn(global, 'fetch');
await runPreamble();
expect(fetchSpy).not.toHaveBeenCalled();
// The translator is configured bare (identity translations), never with
// a pack: English pays zero payload.
expect(mockConfigure).toHaveBeenCalledWith();
fetchSpy.mockRestore();
});
test('fallback fetch requests only the selected locale and blocks until configured', async () => {
mockGetBootstrapData.mockReturnValue(bootstrapData('fr'));
const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
json: () => Promise.resolve(FAKE_PACK),
} as unknown as Response);
await runPreamble();
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(mockMakeUrl).toHaveBeenCalledWith('/language_pack/fr/');
// configure() resolves before initPreamble() does, so nothing renders
// untranslated even on the fallback path.
expect(mockConfigure).toHaveBeenCalledWith({ languagePack: FAKE_PACK });
expect(window.__SUPERSET_LANGUAGE_PACK__).toEqual(FAKE_PACK);
fetchSpy.mockRestore();
});

View File

@@ -65,14 +65,12 @@ export default function initPreamble(): Promise<void> {
setupClient({ appRoot: applicationRoot() });
// Load language pack before rendering.
// Prefer the pack already on hand: either an operator-supplied
// common.language_pack (COMMON_BOOTSTRAP_OVERRIDES_FUNC) or the
// window global set by the versioned script tag spa.html loads
// before this bundle. Either way, module-level `const X = t('...')`
// calls in code-split chunks all see a configured translator. Fall
// back to the async fetch only when neither is present (e.g. the
// script tag failed to load, or a legacy entry that doesn't extend
// spa.html). See issue #35330.
// Prefer the bootstrap-injected pack (stashed on window by the inline
// script in spa.html, sourced from common.language_pack) so
// module-level `const X = t('...')` calls in code-split chunks all
// see a configured translator. Fall back to the async fetch only
// when the bootstrap payload didn't carry the pack (e.g. embedded
// or a legacy entry that doesn't extend spa.html). See issue #35330.
if (lang !== 'en') {
const bootstrapPack =
(bootstrapData.common as { language_pack?: LanguagePack })

View File

@@ -25,11 +25,7 @@ from superset import event_logger, is_feature_enabled
from superset.daos.dashboard import EmbeddedDashboardDAO
from superset.superset_typing import FlaskResponse
from superset.utils import json
from superset.views.base import (
BaseSupersetView,
common_bootstrap_payload,
get_language_pack_template_context,
)
from superset.views.base import BaseSupersetView, common_bootstrap_payload
class EmbeddedView(BaseSupersetView):
@@ -114,5 +110,4 @@ class EmbeddedView(BaseSupersetView):
bootstrap_data=json.dumps(
bootstrap_data, default=json.pessimistic_json_iso_dttm_ser
),
**get_language_pack_template_context(bootstrap_data["common"]),
)

View File

@@ -114,6 +114,10 @@ class MCPNoAuthSourceError(ValueError):
# scope. When introducing a new method permission, add it here.
_METHOD_TO_REQUIRED_SCOPE = {
"read": "superset:read",
# "get" is the read-class permission FAB registers on its security API
# views (User/Role) — those views have no can_read, so tools targeting
# them declare method_permission_name="get".
"get": "superset:read",
"write": "superset:write",
"delete": "superset:write",
# SQL execution (execute_sql, get_chart_sql) runs arbitrary queries and is

View File

@@ -37,6 +37,10 @@ logger = logging.getLogger(__name__)
@tool(
tags=["discovery"],
class_permission_name="Role",
# FAB's security API views register can_get/can_info/... — never
# can_read — so the default "read" method permission can never be
# granted on User/Role, not even to Admin.
method_permission_name="get",
annotations=ToolAnnotations(
title="Get role info",
readOnlyHint=True,

View File

@@ -45,6 +45,10 @@ _DEFAULT_LIST_ROLES_REQUEST = ListRolesRequest()
@tool(
tags=["core"],
class_permission_name="Role",
# FAB's security API views register can_get/can_info/... — never
# can_read — so the default "read" method permission can never be
# granted on User/Role, not even to Admin.
method_permission_name="get",
annotations=ToolAnnotations(
title="List roles",
readOnlyHint=True,

View File

@@ -38,6 +38,10 @@ logger = logging.getLogger(__name__)
@tool(
tags=["discovery"],
class_permission_name="User",
# FAB's security API views register can_get/can_info/... — never
# can_read — so the default "read" method permission can never be
# granted on User/Role, not even to Admin.
method_permission_name="get",
annotations=ToolAnnotations(
title="Get user info",
readOnlyHint=True,

View File

@@ -46,6 +46,10 @@ _DEFAULT_LIST_USERS_REQUEST = ListUsersRequest()
@tool(
tags=["core"],
class_permission_name="User",
# FAB's security API views register can_get/can_info/... — never
# can_read — so the default "read" method permission can never be
# granted on User/Role, not even to Admin.
method_permission_name="get",
annotations=ToolAnnotations(
title="List users",
readOnlyHint=True,

View File

@@ -150,41 +150,35 @@
{% block tail_js %}
{#
Load the language pack BEFORE the entry bundle so module-level
`const X = t('...')` calls across webpack-split chunks all find a
configured translator on first access (upstream issue #35330). The
script sets window.__SUPERSET_LANGUAGE_PACK__ and is content-addressed
(/language_pack/<lang>/<version>/script.js), so browsers cache it as
immutable across page loads and cache-bust automatically whenever
translations change. English emits no tag at all. If the script fails
to load, preamble.ts falls back to the async language pack fetch.
Expose the language pack on window BEFORE the entry bundle loads,
so module-level `const X = t('...')` calls across webpack-split
chunks all find a configured translator on first access. The
bootstrap div is the single source of truth; we parse it once and
stash the pack on a global that the translator reads lazily.
See upstream issue #35330.
Trade-off: this ships the full Jed pack inline in every full-page
HTML response (only for non-English locales; English injects null)
rather than via the separately-cacheable `/language_pack/` fetch.
That synchronous availability is the whole point — the async fetch
can't guarantee the pack is configured before a code-split chunk
evaluates. Acceptable for an SPA where hard reloads are rare.
#}
{% if language_pack_src %}
<script src="{{ language_pack_src }}" nonce="{{ macros.get_nonce() }}"></script>
{% endif %}
{#
Operators can still supply a pack directly in the bootstrap payload
via COMMON_BOOTSTRAP_OVERRIDES_FUNC (the historical #35330
workaround). When one is present, stash it on window here instead of
emitting the versioned script tag, so early chunks see the override.
#}
{% if language_pack_inline %}
<script nonce="{{ macros.get_nonce() }}">
(function () {
try {
var el = document.getElementById('app');
if (!el) return;
var data = JSON.parse(el.getAttribute('data-bootstrap') || '{}');
var pack = data && data.common && data.common.language_pack;
if (pack) {
window.__SUPERSET_LANGUAGE_PACK__ = pack;
}
} catch (e) {
/* swallow: fall back to async fetch in preamble.ts */
<script nonce="{{ macros.get_nonce() }}">
(function () {
try {
var el = document.getElementById('app');
if (!el) return;
var data = JSON.parse(el.getAttribute('data-bootstrap') || '{}');
var pack = data && data.common && data.common.language_pack;
if (pack) {
window.__SUPERSET_LANGUAGE_PACK__ = pack;
}
})();
</script>
{% endif %}
} catch (e) {
/* swallow: fall back to async fetch in preamble.ts */
}
})();
</script>
{% if entry %}
{{ js_bundle(assets_prefix, entry) }}
{% endif %}

View File

@@ -14,7 +14,6 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
import hashlib
import json
import logging
import os
@@ -25,41 +24,9 @@ logger = logging.getLogger(__name__)
# Global caching for JSON language packs
ALL_LANGUAGE_PACKS: dict[str, dict[str, Any]] = {"en": {}}
# Global caching for language pack content hashes, used to build
# content-addressed (cache-busting) asset URLs
ALL_LANGUAGE_PACK_VERSIONS: dict[str, str] = {}
DIR = os.path.dirname(os.path.abspath(__file__))
def get_language_pack_filename(locale: str) -> str:
"""Resolve the on-disk JSON pack for a locale (empty pack for English)"""
if not locale or locale == "en":
# Forcing a dummy, quasi-empty language pack for English since the file
# in the en directory contains data with empty mappings
return DIR + "/empty_language_pack.json"
return DIR + f"/{locale}/LC_MESSAGES/messages.json"
def get_language_pack_version(locale: str) -> Optional[str]:
"""Get/cache a short content hash of the language pack file
The hash is embedded in the pack's asset URL so browsers can cache it
as immutable and pick up a fresh copy whenever translations change.
Returns None when the pack file cannot be read.
"""
version = ALL_LANGUAGE_PACK_VERSIONS.get(locale)
if not version:
try:
with open(get_language_pack_filename(locale), "rb") as f:
version = hashlib.sha256(f.read()).hexdigest()[:12]
ALL_LANGUAGE_PACK_VERSIONS[locale] = version
except OSError:
logger.warning("No language pack file to version for locale %s", locale)
return None
return version
def get_language_pack(locale: str) -> Optional[dict[str, Any]]:
"""Get/cache a language pack
@@ -70,7 +37,11 @@ def get_language_pack(locale: str) -> Optional[dict[str, Any]]:
"""
pack = ALL_LANGUAGE_PACKS.get(locale)
if not pack:
filename = get_language_pack_filename(locale)
filename = DIR + f"/{locale}/LC_MESSAGES/messages.json"
if not locale or locale == "en":
# Forcing a dummy, quasy-empty language pack for English since the file
# in the en directory is contains data with empty mappings
filename = DIR + "/empty_language_pack.json"
try:
with open(filename, encoding="utf8") as f:
pack = json.load(f)

View File

@@ -48,16 +48,23 @@ logger = logging.getLogger(__name__)
def insert_baseline_and_children(
session: Session, obj: Any, version_table: Any
) -> None:
"""Insert the parent baseline row, then baseline the parent's child
collections under the same transaction id.
"""Insert one atomic baseline for a parent and its children.
Wrapped in ``no_autoflush`` so ``session.connection()`` inside
``_insert_baseline_row`` does not trigger a flush of Continuum's
pending Transaction object before our direct-SQL insert claims its
tx_id.
tx_id. A connection-level SAVEPOINT is required because this helper runs
inside ``before_flush``, where ``Session.begin_nested()`` would flush
recursively. One SAVEPOINT contains the transaction row, parent shadow,
and every child shadow so optional baseline capture is all-or-nothing and
a failed PostgreSQL statement cannot poison the user's transaction.
Continuum runs later in the same ``before_flush`` listener chain. The
PostgreSQL failure tests assert that releasing or rolling back this
SAVEPOINT preserves Continuum's canonical update shadow and transaction.
"""
try:
with session.no_autoflush:
with session.no_autoflush, session.connection().begin_nested():
tx_id = _insert_baseline_row(session, obj, version_table)
if tx_id is None:
return
@@ -132,18 +139,12 @@ def _baseline_children_for_parent(
Dispatches via the
:data:`~superset.versioning.baseline.children.CHILD_BASELINE_HANDLERS`
table to per-entity handlers. A handler failure is logged but does
not block the parent baseline.
table to per-entity handlers. Handler failures propagate to
:func:`insert_baseline_and_children`, which rolls back the complete
optional baseline unit before logging the failure.
"""
parent_name = type(parent_obj).__name__
handler = CHILD_BASELINE_HANDLERS.get(parent_name)
if handler is None:
return
try:
handler(session, parent_obj, tx_id)
except Exception: # pylint: disable=broad-except
logger.exception(
"baseline_listener: failed to baseline children of %s id=%s",
parent_name,
getattr(parent_obj, "id", None),
)
handler(session, parent_obj, tx_id)

View File

@@ -66,7 +66,7 @@ from superset.themes.types import Theme, ThemeMode
from superset.themes.utils import (
is_valid_theme,
)
from superset.translations.utils import get_language_pack_version
from superset.translations.utils import get_language_pack
from superset.utils import core as utils, json
from superset.utils.filters import get_dataset_access_filters
from superset.utils.version import get_version_metadata, visible_version_metadata
@@ -611,50 +611,25 @@ def common_bootstrap_payload() -> dict[str, Any]:
# Convert locale to string for proper cache key hashing
locale_str = str(locale) if locale else None
payload = dict(cached_common_bootstrap_data(utils.get_user_id(), locale_str))
# The language pack itself is NOT embedded in the payload: spa.html loads
# it through the content-addressed /language_pack/<lang>/<version>/script.js
# tag before the entry bundle, keeping HTML small while still configuring
# the translator synchronously (upstream issue #35330). A pack provided via
# COMMON_BOOTSTRAP_OVERRIDES_FUNC (the historical workaround) is respected
# and takes precedence over the script tag.
payload.setdefault("language_pack", None)
# Inject the Jed language pack outside the per-user memoize so the cached
# payload stays small and the pack is shared across users for the same
# locale. The frontend uses it to configure the translator synchronously,
# before any code-split chunk evaluates a module-level `const X = t('...')`
# (upstream issue #35330).
language = payload.get("locale")
if language and language != "en":
# Respect a pack already provided via COMMON_BOOTSTRAP_OVERRIDES_FUNC
# (the workaround in #35330 does exactly that), otherwise load the
# shared one. `get_language_pack` returns the empty English pack on a
# miss, which is the right result (English) when no translation file
# exists.
pack = payload.get("language_pack") or get_language_pack(language)
else:
pack = None
payload["language_pack"] = pack
return payload
def get_language_pack_template_context(common: dict[str, Any]) -> dict[str, Any]:
"""Template vars controlling how spa.html delivers the language pack.
``common`` is the already-built common bootstrap payload for the request
(passed in rather than re-derived, so callers that assembled or mocked
their own payload stay consistent with what the template sees).
Three mutually exclusive outcomes:
- English (or no locale): no script tag, nothing to stash.
- Operator supplied a pack via COMMON_BOOTSTRAP_OVERRIDES_FUNC: no script
tag; spa.html stashes the bootstrap pack on window instead.
- Otherwise: emit the content-addressed script URL so the browser can
cache the pack as immutable and cache-bust on translation changes.
"""
language = common.get("locale")
if not language or language == "en":
return {"language_pack_src": None, "language_pack_inline": False}
if common.get("language_pack"):
return {"language_pack_src": None, "language_pack_inline": True}
version = get_language_pack_version(language)
return {
"language_pack_src": (
url_for(
"Superset.language_pack_script",
lang=language,
version=version,
)
if version
else None
),
"language_pack_inline": False,
}
def get_spa_payload(extra_data: dict[str, Any] | None = None) -> dict[str, Any]:
"""Generate standardized payload for spa.html template rendering.
@@ -753,7 +728,6 @@ def get_spa_template_context(
"dark_theme_bg": dark_theme_bg,
"spinner_svg": spinner_svg,
"default_title": default_title,
**get_language_pack_template_context(payload.get("common") or {}),
**template_kwargs,
}

View File

@@ -22,7 +22,7 @@ import logging
import os
import re
from datetime import datetime
from typing import Any, Callable, cast, Optional
from typing import Any, Callable, cast
from urllib import parse
from flask import (
@@ -82,7 +82,6 @@ from superset.superset_typing import (
FlaskResponse,
)
from superset.tasks.utils import get_current_user
from superset.translations.utils import get_language_pack, get_language_pack_version
from superset.utils import core as utils, json
from superset.utils.cache import etag_cache
from superset.utils.core import (
@@ -130,13 +129,6 @@ PARAMETER_MISSING_ERR = __(
SqlResults = dict[str, Any]
# Matches the locale codes Superset actually ships: a 2-3 letter language
# subtag, optionally followed by a 2-letter region subtag ("pt_BR") or a
# 4-letter script subtag ("sr_Latn").
LANGUAGE_CODE_RE: re.Pattern[str] = re.compile(
r"^[a-z]{2,3}(_[A-Z]{2}|_[A-Z][a-z]{3})?$"
)
class Superset(BaseSupersetView):
"""The base views for Superset!"""
@@ -927,7 +919,7 @@ class Superset(BaseSupersetView):
@expose("/language_pack/<lang>/")
def language_pack(self, lang: str) -> FlaskResponse:
# Only allow expected language formats like "en", "pt_BR", etc.
if not LANGUAGE_CODE_RE.match(lang):
if not re.match(r"^[a-z]{2,3}(_[A-Z]{2})?$", lang):
abort(400, "Invalid language code")
base_dir = os.path.join(os.path.dirname(__file__), "..", "translations")
@@ -940,52 +932,6 @@ class Superset(BaseSupersetView):
"Language pack doesn't exist on the server", status=404
)
@expose("/language_pack/<lang>/<version>/script.js")
def language_pack_script(self, lang: str, version: str) -> FlaskResponse:
"""Serve the language pack as a content-addressed classic script.
spa.html loads this BEFORE the entry bundle so translations are
configured synchronously (no race with code-split chunks), while the
versioned URL lets browsers cache the pack as immutable and pick up a
fresh copy whenever translations change.
Deliberately unauthenticated: translation catalogs are static, public
content shipped in the Superset repo, contain no user or tenant data,
and must load for anonymous principals (login page, embedded).
"""
# Only allow expected language formats like "en", "pt_BR", etc.
if not LANGUAGE_CODE_RE.match(lang):
abort(400, "Invalid language code")
if not re.match(r"^[0-9a-f]{12}$", version):
abort(400, "Invalid language pack version")
current_version: Optional[str] = get_language_pack_version(lang)
if current_version is None:
return json_error_response(
"Language pack doesn't exist on the server", status=404
)
pack: Optional[dict[str, Any]] = get_language_pack(lang)
if pack is None:
return json_error_response(
"Language pack doesn't exist on the server", status=404
)
response: Response = Response(
f"window.__SUPERSET_LANGUAGE_PACK__ = {json.dumps(pack)};",
mimetype="application/javascript; charset=utf-8",
)
if version == current_version:
# Content-addressed URL: safe to cache forever.
response.cache_control.public = True
response.cache_control.max_age = 31536000
response.cache_control.immutable = True
else:
# Stale or unknown version (e.g. HTML rendered before an upgrade):
# serve the current pack but keep caches from pinning it under the
# wrong address.
response.cache_control.no_cache = True
return response
@event_logger.log_this
@expose("/welcome/")
def welcome(self) -> FlaskResponse:

View File

@@ -1740,10 +1740,6 @@ class TestRolePermission(SupersetTestCase):
# Serves the PWA web app manifest unauthenticated (PWA install
# fetches have no session); mirrors the RedirectView precedent.
["PwaManifestView", "manifest"],
# Serves translation catalogs (static public repo content, no
# user/tenant data) as content-addressed scripts; must load for
# anonymous principals (login page, embedded dashboards).
["Superset", "language_pack_script"],
]
unsecured_views = []
for view_class in appbuilder.baseviews:

View File

@@ -65,7 +65,8 @@ def _assert_table_list_covers_schema() -> None:
)
def _clear_version_tables() -> None:
def clear_version_tables() -> None:
"""Delete all entity-versioning capture rows and commit the cleanup."""
with app.app_context():
_assert_table_list_covers_schema()
for table in _VERSION_TABLES:
@@ -88,6 +89,6 @@ def clean_version_tables() -> Iterator[None]:
Table names are interpolated from the fixed ``_VERSION_TABLES`` tuple, not
from any test input, so the ``DELETE`` is not an injection surface.
"""
_clear_version_tables()
clear_version_tables()
yield
_clear_version_tables()
clear_version_tables()

View File

@@ -28,9 +28,13 @@ The suite runs with ``ENABLE_VERSIONING_CAPTURE=True`` (see
autouse fixture clears the version tables around each test.
"""
from unittest.mock import patch
from uuid import uuid4
import pytest
import sqlalchemy as sa
from sqlalchemy.engine import Connection
from sqlalchemy.orm import Session
from superset import db
from superset.connectors.sqla.models import SqlaTable
@@ -43,6 +47,7 @@ from tests.integration_tests.fixtures.birth_names_dashboard import (
load_birth_names_dashboard_with_slices, # noqa: F401
load_birth_names_data, # noqa: F401
)
from tests.integration_tests.versioning.conftest import clear_version_tables
# A syntactically valid UUID that matches no entity / version.
MISSING_UUID = str(uuid4())
@@ -202,6 +207,73 @@ class TestChartVersionsApi(SupersetTestCase):
finally:
self.client.put(f"/api/v1/chart/{chart_id}", json={"slice_name": "Girls"})
def test_parent_baseline_sql_failure_does_not_break_save(self) -> None:
"""A failed optional parent baseline leaves the save committable."""
chart_id = self._girls_chart().id
clear_version_tables()
self.login(ADMIN_USERNAME)
def fail_parent_baseline(
conn: Connection, *args: object, **kwargs: object
) -> None:
conn.execute(
sa.text("INSERT INTO __missing_parent_shadow__ (x) VALUES (1)")
)
try:
with patch(
"superset.versioning.baseline.insertion.insert_baseline_shadow_row",
side_effect=fail_parent_baseline,
):
rv = self.client.put(
f"/api/v1/chart/{chart_id}",
json={"slice_name": "Girls survives parent baseline failure"},
)
assert rv.status_code == 200, rv.data
got = json.loads(self.client.get(f"/api/v1/chart/{chart_id}").data)[
"result"
]
assert got["slice_name"] == "Girls survives parent baseline failure"
baseline_count = db.session.scalar(
sa.text(
"SELECT count(*) FROM slices_version "
"WHERE id = :id AND operation_type = 0"
),
{"id": chart_id},
)
assert baseline_count == 0
canonical_updates = db.session.scalar(
sa.text(
"SELECT count(*) FROM slices_version "
"WHERE id = :id AND operation_type = 1 "
"AND slice_name = :name"
),
{
"id": chart_id,
"name": "Girls survives parent baseline failure",
},
)
assert canonical_updates == 1
assert (
db.session.scalar(sa.text("SELECT count(*) FROM version_transaction"))
== 1
)
listing = json.loads(
self.client.get(f"/api/v1/chart/{got['uuid']}/versions/").data
)
assert listing["count"] == 1
assert listing["result"][0]["operation_type"] == "update"
follow_up = self.client.put(
f"/api/v1/chart/{chart_id}",
json={"slice_name": "Girls session remains usable"},
)
assert follow_up.status_code == 200, follow_up.data
finally:
self.client.put(f"/api/v1/chart/{chart_id}", json={"slice_name": "Girls"})
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
class TestDashboardVersionsApi(SupersetTestCase):
@@ -301,3 +373,86 @@ class TestDatasetVersionsApi(SupersetTestCase):
body = json.loads(rv.data.decode("utf-8"))
assert set(body) >= {"old_version", "new_version", "new_version_uuid"}
assert body["new_version_uuid"] is not None
def test_child_baseline_sql_failure_rolls_back_complete_optional_unit(
self,
) -> None:
"""A child failure preserves the save without partial baseline rows."""
dataset = self._dataset()
dataset_id = dataset.id
original = dataset.description
clear_version_tables()
self.login(ADMIN_USERNAME)
def fail_children(session: Session, _parent: object, _tx_id: int) -> None:
session.connection().execute(
sa.text("INSERT INTO __missing_child_shadow__ (x) VALUES (1)")
)
try:
with patch.dict(
"superset.versioning.baseline.insertion.CHILD_BASELINE_HANDLERS",
{"SqlaTable": fail_children},
):
rv = self.client.put(
f"/api/v1/dataset/{dataset_id}",
json={"description": "survives child baseline failure"},
)
assert rv.status_code == 200, rv.data
got = json.loads(self.client.get(f"/api/v1/dataset/{dataset_id}").data)[
"result"
]
assert got["description"] == "survives child baseline failure"
parent_baselines = db.session.scalar(
sa.text(
"SELECT count(*) FROM tables_version "
"WHERE id = :id AND operation_type = 0"
),
{"id": dataset_id},
)
child_baselines = db.session.scalar(
sa.text(
"SELECT count(*) FROM table_columns_version "
"WHERE table_id = :id AND operation_type = 0"
),
{"id": dataset_id},
)
metric_baselines = db.session.scalar(
sa.text(
"SELECT count(*) FROM sql_metrics_version "
"WHERE table_id = :id AND operation_type = 0"
),
{"id": dataset_id},
)
assert parent_baselines == 0
assert child_baselines == 0
assert metric_baselines == 0
canonical_updates = db.session.scalar(
sa.text(
"SELECT count(*) FROM tables_version "
"WHERE id = :id AND operation_type = 1 "
"AND description = :description"
),
{
"id": dataset_id,
"description": "survives child baseline failure",
},
)
assert canonical_updates == 1
assert (
db.session.scalar(sa.text("SELECT count(*) FROM version_transaction"))
== 1
)
listing = json.loads(
self.client.get(f"/api/v1/dataset/{got['uuid']}/versions/").data
)
assert listing["count"] == 1
assert listing["result"][0]["operation_type"] == "update"
finally:
self.client.put(
f"/api/v1/dataset/{dataset_id}",
json={"description": original},
)

View File

@@ -478,3 +478,69 @@ def test_scope_execute_sql_query_requires_write_scope(app_context) -> None:
assert check_tool_permission(func) is False
with _patch_token_scopes(["superset:write"]):
assert check_tool_permission(func) is True
# ---------------------------------------------------------------------------
# User/Role tools must request a permission FAB actually registers.
#
# FAB's security API views (UserApi, RoleApi) register only
# can_get/can_info/can_post/can_put/can_delete — never can_read. Superset's
# own ModelRestApi subclasses remap methods onto read/write, which is why
# "can_read on Chart" exists while "can_read on User" cannot. A tool that
# demands can_read on User/Role is therefore dead for every principal,
# including Admin (fails closed).
# ---------------------------------------------------------------------------
# The full set of permissions FAB registers on its security API views.
_FAB_SECURITY_VIEW_PERMISSIONS = {
"can_get",
"can_info",
"can_post",
"can_put",
"can_delete",
}
def _fab_security_view_can_access(permission_str: str, view_name: str) -> bool:
"""Simulate can_access for a user holding every permission FAB actually
registers on the User/Role security views (i.e. a stock Admin)."""
return permission_str in _FAB_SECURITY_VIEW_PERMISSIONS
@pytest.mark.parametrize(
"module_path,func_name",
[
("superset.mcp_service.user.tool.list_users", "list_users"),
("superset.mcp_service.user.tool.get_user_info", "get_user_info"),
("superset.mcp_service.role.tool.list_roles", "list_roles"),
("superset.mcp_service.role.tool.get_role_info", "get_role_info"),
],
)
def test_user_role_tools_usable_by_stock_admin(
app_context, module_path: str, func_name: str
) -> None:
"""A stock Admin (holding exactly the FAB-registered permissions on the
User/Role views) must be able to use the user/role tools."""
import importlib
func = getattr(importlib.import_module(module_path), func_name)
g.user = MagicMock(username="admin")
mock_sm = MagicMock()
mock_sm.can_access = MagicMock(side_effect=_fab_security_view_can_access)
with patch("superset.mcp_service.auth.security_manager", mock_sm):
assert check_tool_permission(func) is True, (
f"{func_name} requests can_"
f"{getattr(func, METHOD_PERMISSION_ATTR, 'read')} on "
f"{getattr(func, CLASS_PERMISSION_ATTR, None)}, which FAB never "
"registers on its security views — the tool is dead even for Admin"
)
def test_get_method_permission_is_scope_mapped(app_context) -> None:
"""The 'get' method permission must be in _METHOD_TO_REQUIRED_SCOPE:
unmapped method permissions are denied for scoped tokens (fail closed),
which would leave the user/role tools dead for scoped-JWT deployments."""
from superset.mcp_service.auth import _METHOD_TO_REQUIRED_SCOPE
assert _METHOD_TO_REQUIRED_SCOPE.get("get") == "superset:read"

View File

@@ -1,16 +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.

View File

@@ -1,71 +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.
import hashlib
from collections.abc import Iterator
import pytest
from superset.translations import utils as translations_utils
from superset.translations.utils import (
get_language_pack_filename,
get_language_pack_version,
)
@pytest.fixture(autouse=True)
def _clear_version_cache() -> Iterator[None]:
translations_utils.ALL_LANGUAGE_PACK_VERSIONS.clear()
yield
translations_utils.ALL_LANGUAGE_PACK_VERSIONS.clear()
def test_language_pack_filename_resolution() -> None:
assert get_language_pack_filename("fr").endswith("/fr/LC_MESSAGES/messages.json")
assert get_language_pack_filename("en").endswith("/empty_language_pack.json")
assert get_language_pack_filename("").endswith("/empty_language_pack.json")
def test_version_is_short_content_hash(tmp_path, monkeypatch) -> None:
pack_file = tmp_path / "fr" / "LC_MESSAGES" / "messages.json"
pack_file.parent.mkdir(parents=True)
pack_file.write_bytes(b'{"domain": "superset"}')
monkeypatch.setattr(translations_utils, "DIR", str(tmp_path))
version = get_language_pack_version("fr")
expected = hashlib.sha256(b'{"domain": "superset"}').hexdigest()[:12]
assert version == expected
def test_version_is_cached_and_changes_with_content(tmp_path, monkeypatch) -> None:
pack_file = tmp_path / "fr" / "LC_MESSAGES" / "messages.json"
pack_file.parent.mkdir(parents=True)
pack_file.write_bytes(b"{}")
monkeypatch.setattr(translations_utils, "DIR", str(tmp_path))
first = get_language_pack_version("fr")
pack_file.write_bytes(b'{"changed": true}')
# Cached within the process lifetime: same version until cache cleared.
assert get_language_pack_version("fr") == first
translations_utils.ALL_LANGUAGE_PACK_VERSIONS.clear()
assert get_language_pack_version("fr") != first
def test_version_none_when_pack_missing(tmp_path, monkeypatch) -> None:
monkeypatch.setattr(translations_utils, "DIR", str(tmp_path))
assert get_language_pack_version("xx") is None

View File

@@ -156,108 +156,71 @@ def test_bootstrap_does_not_crash_without_recaptcha_key(
assert "RECAPTCHA_PUBLIC_KEY" not in payload["conf"]
# --- language pack delivery ----------------------------------------------
# --- language_pack injection --------------------------------------------
#
# The pack is NOT embedded in the bootstrap payload; spa.html loads it via a
# content-addressed script tag whose URL comes from
# `get_language_pack_template_context`. A pack supplied through
# COMMON_BOOTSTRAP_OVERRIDES_FUNC still rides the payload and suppresses the
# script tag.
# The Jed pack is injected by `common_bootstrap_payload` (outside the
# memoized `cached_common_bootstrap_data`) using the shared
# `superset.translations.utils.get_language_pack`. Tests here cover the
# wrapper to confirm the pack lands on the payload for non-English
# locales and is None for English.
def test_common_bootstrap_payload_does_not_embed_language_pack(
def test_common_bootstrap_payload_includes_language_pack_for_non_english(
app_context: None,
) -> None:
"""The payload stays small: no full pack even for non-English locales."""
"""common.language_pack carries the shared utility's pack for non-en."""
fake_pack = {"domain": "superset", "locale_data": {"superset": {}}}
with (
patch(
"superset.views.base.cached_common_bootstrap_data",
return_value={"locale": "fr"},
),
patch(
"superset.views.base.get_language_pack",
return_value=fake_pack,
) as mock_get,
patch("superset.views.base.utils.get_user_id", return_value=1),
patch("superset.views.base.get_locale", return_value="fr"),
):
payload: dict[str, Any] = common_bootstrap_payload()
payload = common_bootstrap_payload()
assert payload["language_pack"] is None
assert payload["language_pack"] == fake_pack
mock_get.assert_called_once_with("fr")
def test_common_bootstrap_payload_preserves_override_pack(
def test_common_bootstrap_payload_skips_pack_for_english(
app_context: None,
) -> None:
"""A pack from COMMON_BOOTSTRAP_OVERRIDES_FUNC is passed through."""
fake_pack: dict[str, Any] = {"domain": "superset", "locale_data": {"superset": {}}}
"""English short-circuits: pack is None and the utility is not called."""
with (
patch(
"superset.views.base.cached_common_bootstrap_data",
return_value={"locale": "fr", "language_pack": fake_pack},
return_value={"locale": "en"},
),
patch("superset.views.base.get_language_pack") as mock_get,
patch("superset.views.base.utils.get_user_id", return_value=1),
patch("superset.views.base.get_locale", return_value="fr"),
patch("superset.views.base.get_locale", return_value="en"),
):
payload: dict[str, Any] = common_bootstrap_payload()
payload = common_bootstrap_payload()
assert payload["language_pack"] == fake_pack
assert payload["language_pack"] is None
mock_get.assert_not_called()
def test_common_bootstrap_payload_does_not_mutate_memoized_dict(
app_context: None,
) -> None:
"""Defaulting language_pack must not write back into the memoize cache."""
"""Injecting language_pack must not write back into the memoize cache."""
cached: dict[str, Any] = {"locale": "fr"}
with (
patch(
"superset.views.base.cached_common_bootstrap_data",
return_value=cached,
),
patch("superset.views.base.get_language_pack", return_value={"x": 1}),
patch("superset.views.base.utils.get_user_id", return_value=1),
patch("superset.views.base.get_locale", return_value="fr"),
):
common_bootstrap_payload()
assert "language_pack" not in cached
def _language_pack_context(
locale: str, payload_extra: dict[str, Any]
) -> dict[str, Any]:
from superset.views.base import get_language_pack_template_context
with (
patch(
"superset.views.base.get_language_pack_version",
return_value="abc123def456",
),
patch(
"superset.views.base.url_for",
return_value="/language_pack/fr/abc123def456/script.js",
),
):
return get_language_pack_template_context({"locale": locale, **payload_extra})
def test_language_pack_template_context_versioned_src_for_non_english(
app_context: None,
) -> None:
context: dict[str, Any] = _language_pack_context("fr", {})
assert context == {
"language_pack_src": "/language_pack/fr/abc123def456/script.js",
"language_pack_inline": False,
}
def test_language_pack_template_context_none_for_english(
app_context: None,
) -> None:
context: dict[str, Any] = _language_pack_context("en", {})
assert context == {"language_pack_src": None, "language_pack_inline": False}
def test_language_pack_template_context_inline_for_override_pack(
app_context: None,
) -> None:
"""An operator-supplied pack suppresses the script tag; spa.html inlines."""
context: dict[str, Any] = _language_pack_context(
"fr", {"language_pack": {"domain": "superset"}}
)
assert context == {"language_pack_src": None, "language_pack_inline": True}

View File

@@ -1,130 +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 pathlib import Path
from typing import Any
from unittest.mock import patch
FAKE_PACK = {"domain": "superset", "locale_data": {"superset": {"": {}}}}
FAKE_VERSION = "abc123def456"
def test_script_served_immutable_when_version_matches(client: Any) -> None:
with (
patch(
"superset.views.core.get_language_pack_version",
return_value=FAKE_VERSION,
),
patch("superset.views.core.get_language_pack", return_value=FAKE_PACK),
):
response = client.get(f"/language_pack/fr/{FAKE_VERSION}/script.js")
assert response.status_code == 200
assert response.mimetype == "application/javascript"
body = response.get_data(as_text=True)
assert body.startswith("window.__SUPERSET_LANGUAGE_PACK__ = ")
assert '"domain": "superset"' in body
cache_control = response.headers["Cache-Control"]
assert "immutable" in cache_control
assert "max-age=31536000" in cache_control
assert "public" in cache_control
def test_script_not_cacheable_when_version_stale(client: Any) -> None:
"""A pre-upgrade HTML page may reference an old version: serve fresh
content, but do not let caches pin it under the stale address."""
with (
patch(
"superset.views.core.get_language_pack_version",
return_value=FAKE_VERSION,
),
patch("superset.views.core.get_language_pack", return_value=FAKE_PACK),
):
response = client.get("/language_pack/fr/000000000000/script.js")
assert response.status_code == 200
assert "no-cache" in response.headers["Cache-Control"]
assert "immutable" not in response.headers["Cache-Control"]
def test_script_404_when_pack_missing(client: Any) -> None:
with patch(
"superset.views.core.get_language_pack_version",
return_value=None,
):
response = client.get(f"/language_pack/xx/{FAKE_VERSION}/script.js")
assert response.status_code == 404
def test_script_rejects_malformed_lang_and_version(client: Any) -> None:
assert client.get(f"/language_pack/../{FAKE_VERSION}/script.js").status_code in (
400,
404,
)
assert client.get("/language_pack/fr/not-a-hash!/script.js").status_code == 400
def test_script_accepts_script_subtag_locale(client: Any) -> None:
"""Script-subtag locales Superset ships (e.g. sr_Latn) must not be
rejected by the language-code validation."""
with (
patch(
"superset.views.core.get_language_pack_version",
return_value=FAKE_VERSION,
),
patch("superset.views.core.get_language_pack", return_value=FAKE_PACK),
):
response = client.get(f"/language_pack/sr_Latn/{FAKE_VERSION}/script.js")
assert response.status_code == 200
def test_script_serves_only_the_requested_locale(client: Any) -> None:
"""The endpoint resolves exactly one pack: the locale in the URL."""
with (
patch(
"superset.views.core.get_language_pack_version",
return_value=FAKE_VERSION,
) as mock_version,
patch(
"superset.views.core.get_language_pack", return_value=FAKE_PACK
) as mock_pack,
):
client.get(f"/language_pack/pt_BR/{FAKE_VERSION}/script.js")
mock_version.assert_called_once_with("pt_BR")
mock_pack.assert_called_once_with("pt_BR")
def test_spa_template_loads_pack_before_entry_bundle() -> None:
"""Static guard on spa.html: the language pack script tag must precede
the entry bundle and stay a classic script (no async/defer). A deferred
or reordered tag would let code-split chunks evaluate module-level
`t('...')` calls before the translator is configured (issue #35330)."""
import superset
template: str = (
Path(superset.__file__).parent / "templates" / "superset" / "spa.html"
).read_text()
tag_start: int = template.index('<script src="{{ language_pack_src }}"')
entry_start: int = template.index("js_bundle(assets_prefix, entry)")
assert tag_start < entry_start
script_tag: str = template[tag_start : template.index(">", tag_start)]
assert "async" not in script_tag
assert "defer" not in script_tag