Compare commits

...

5 Commits

Author SHA1 Message Date
Evan
fc6fe3a76d chore(config): correct SMTP_SSL_SERVER_AUTH comment to SERVER_AUTH
ssl.create_default_context() defaults to ssl.Purpose.SERVER_AUTH, which is
correct for Superset acting as an SMTP client verifying the mail server's
certificate. The comment incorrectly referenced CLIENT_AUTH.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 10:24:12 -07:00
Evan
42d6e5217a test(config): cover SMTP_SSL_SERVER_AUTH enabled behavior
Add unit tests in config_test.py that exercise send_mime_email and assert
ssl.create_default_context() is called and its context is threaded through
to SMTP_SSL and starttls when SMTP_SSL_SERVER_AUTH=True, plus the opt-out
path passing context=None. Complements the existing module-level default
assertion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 10:21:19 -07:00
Evan
4280db194c test(email): explicitly opt out of SMTP_SSL_SERVER_AUTH in test_send_mime_ssl
The new default for SMTP_SSL_SERVER_AUTH is True. test_send_mime_ssl
tests the no-server-auth code path and must explicitly set the flag to
False to avoid asserting context=None when the default now produces an
SSL context.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 10:21:19 -07:00
Claude Code
45f0743e1a chore(config): default SMTP_SSL_SERVER_AUTH to True
Change the shipped default for SMTP_SSL_SERVER_AUTH from False to True so
STARTTLS/SSL connections to the SMTP server validate the server's TLS
certificate against the system CA store out of the box.

The setting remains overridable: operators using a self-signed or otherwise
untrusted certificate can restore the previous behavior by setting
SMTP_SSL_SERVER_AUTH = False in superset_config.py.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 10:21:18 -07:00
Joe Li
f27424d72e fix(embedded): show filter bar controls on embedded dashboards with tabs (#39417)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-10 09:53:10 -07:00
6 changed files with 706 additions and 14 deletions

View File

@@ -70,6 +70,18 @@ superset revoke-guest-tokens
This change is backward compatible. The feature is off by default, and even when enabled nothing is revoked until an admin explicitly bumps the version: the expected version starts at `0`, and tokens minted before this change (which carry no version claim) are treated as version `0`. No database migration is required.
### SMTP server certificate validation enabled by default
`SMTP_SSL_SERVER_AUTH` now defaults to `True` (previously `False`). With this default, STARTTLS/SSL connections to the configured SMTP server validate the server's TLS certificate against the system trusted CA store. This makes outbound email (alerts and reports) verify the mail server's identity out of the box.
If your SMTP server presents a self-signed certificate, or a certificate that is not trusted by the system CA store, email delivery may now fail with a certificate verification error. To restore the previous behavior of skipping certificate validation, set the following in `superset_config.py`:
```python
SMTP_SSL_SERVER_AUTH = False
```
The recommended fix is to add the SMTP server's certificate (or its issuing CA) to the system trust store rather than disabling validation.
### Dataset import validates catalog against the target connection
Importing a dataset now validates the `catalog` field against the target database connection. When the connection has multi-catalog disabled (`allow_multi_catalog` off) and the dataset's catalog is not the connection's default catalog, the import fails instead of silently persisting the non-default catalog. This matches the validation already enforced on the dataset update path and prevents imported datasets from querying an unintended database.

View File

@@ -41,11 +41,21 @@ jest.mock('react-redux', () => {
});
beforeEach(() => {
(useSelector as jest.Mock).mockImplementation(selector => {
if (selector.name === 'useActiveDashboardTabs') {
return ['TAB_1'];
}
return [];
(useSelector as jest.Mock).mockImplementation((selector: Function) => {
const mockState = {
dashboardState: { activeTabs: ['TAB_1'] },
dashboardLayout: {
present: {
'CHART-123': {
type: 'CHART',
meta: { chartId: 123 },
parents: ['ROOT_ID', 'TAB_1'],
},
TAB_1: { type: 'TAB', id: 'TAB_1' },
},
},
};
return selector(mockState);
});
});
@@ -99,6 +109,14 @@ test('useIsFilterInScope should return false for filters with inactive rootPath'
});
test('useSelectFiltersInScope should return all filters in scope when no tabs exist', () => {
(useSelector as jest.Mock).mockImplementation((selector: Function) => {
const mockState = {
dashboardState: { activeTabs: [] },
dashboardLayout: { present: {} },
};
return selector(mockState);
});
const filters: Filter[] = [
{
id: 'filter_1',
@@ -601,3 +619,479 @@ test('useChartCustomizationConfiguration ignores undefined items in metadata', (
expect.objectContaining({ id: 'CHART_CUSTOMIZATION-1' }),
);
});
// --- Embedded / hideTab: activeTabs is empty ---
// When an embedded dashboard uses hideTab:true, the Tabs component never
// mounts, so setActiveTab never fires and activeTabs stays []. The same
// empty state occurs transiently on first render of any tabbed dashboard.
//
// useActiveDashboardTabs derives the default first tab from the layout when
// Redux activeTabs is empty, so scope evaluation uses the correct default
// tab instead of either "no tabs active" (blank filter bar) or "all tabs"
// (showing out-of-scope filters).
// Helper: build a layout with ROOT_ID → TABS container → TAB children
function embeddedLayout(extras: Record<string, Record<string, unknown>> = {}) {
return {
ROOT_ID: {
type: 'ROOT',
id: 'ROOT_ID',
children: ['TABS-1'],
},
'TABS-1': {
type: 'TABS',
id: 'TABS-1',
children: ['TAB-Company', 'TAB-Desktop'],
},
'TAB-Company': {
type: 'TAB',
id: 'TAB-Company',
children: ['CHART-1', 'CHART-2'],
},
'TAB-Desktop': {
type: 'TAB',
id: 'TAB-Desktop',
children: ['CHART-3'],
},
'CHART-1': {
type: 'CHART',
meta: { chartId: 1 },
parents: ['ROOT_ID', 'TAB-Company'],
},
'CHART-2': {
type: 'CHART',
meta: { chartId: 2 },
parents: ['ROOT_ID', 'TAB-Company'],
},
'CHART-3': {
type: 'CHART',
meta: { chartId: 3 },
parents: ['ROOT_ID', 'TAB-Desktop'],
},
...extras,
};
}
test('useIsFilterInScope: filter scoped to default tab is in-scope when activeTabs is empty', () => {
(useSelector as jest.Mock).mockImplementation((selector: Function) => {
const mockState = {
dashboardState: { activeTabs: [] },
dashboardLayout: { present: embeddedLayout() },
};
return selector(mockState);
});
const filter: Filter = {
id: 'filter_default_tab',
name: 'Default Tab Filter',
filterType: 'filter_select',
type: NativeFilterType.NativeFilter,
chartsInScope: [1, 2],
scope: { rootPath: ['TAB-Company'], excluded: [] },
controlValues: {},
defaultDataMask: {},
cascadeParentIds: [],
targets: [{ column: { name: 'column_name' }, datasetId: 1 }],
description: 'Filter scoped to default (first) tab',
};
const { result } = renderHook(() => useIsFilterInScope());
expect(result.current(filter)).toBe(true);
});
test('useIsFilterInScope: filter scoped only to non-default tab is out-of-scope when activeTabs is empty', () => {
(useSelector as jest.Mock).mockImplementation((selector: Function) => {
const mockState = {
dashboardState: { activeTabs: [] },
dashboardLayout: { present: embeddedLayout() },
};
return selector(mockState);
});
const filter: Filter = {
id: 'filter_other_tab',
name: 'Other Tab Filter',
filterType: 'filter_select',
type: NativeFilterType.NativeFilter,
chartsInScope: [3],
scope: { rootPath: ['TAB-Desktop'], excluded: [] },
controlValues: {},
defaultDataMask: {},
cascadeParentIds: [],
targets: [{ column: { name: 'column_name' }, datasetId: 1 }],
description: 'Filter scoped only to non-default tab',
};
const { result } = renderHook(() => useIsFilterInScope());
expect(result.current(filter)).toBe(false);
});
test('useIsFilterInScope: filter with rootPath to default tab is in-scope when activeTabs is empty', () => {
(useSelector as jest.Mock).mockImplementation((selector: Function) => {
const mockState = {
dashboardState: { activeTabs: [] },
dashboardLayout: { present: embeddedLayout() },
};
return selector(mockState);
});
const filter: Filter = {
id: 'filter_rootpath_default',
name: 'RootPath Filter',
filterType: 'filter_select',
type: NativeFilterType.NativeFilter,
scope: { rootPath: ['TAB-Company'], excluded: [] },
controlValues: {},
defaultDataMask: {},
cascadeParentIds: [],
targets: [{ column: { name: 'column_name' }, datasetId: 1 }],
description: 'Filter using rootPath to default tab',
};
const { result } = renderHook(() => useIsFilterInScope());
expect(result.current(filter)).toBe(true);
});
test('useSelectFiltersInScope: only default-tab filters are in scope when activeTabs is empty (embedded hideTab)', () => {
(useSelector as jest.Mock).mockImplementation((selector: Function) => {
const mockState = {
dashboardState: { activeTabs: [] },
dashboardLayout: { present: embeddedLayout() },
};
return selector(mockState);
});
const filters: Filter[] = [
{
id: 'filter_company',
name: 'Company Tab Filter',
filterType: 'filter_select',
type: NativeFilterType.NativeFilter,
chartsInScope: [1, 2],
scope: { rootPath: ['TAB-Company'], excluded: [] },
controlValues: {},
defaultDataMask: {},
cascadeParentIds: [],
targets: [{ column: { name: 'survey_rating' }, datasetId: 1 }],
description: 'Filter scoped to default tab',
},
{
id: 'filter_desktop_only',
name: 'Desktop Only Filter',
filterType: 'filter_select',
type: NativeFilterType.NativeFilter,
chartsInScope: [3],
scope: { rootPath: ['TAB-Desktop'], excluded: [] },
controlValues: {},
defaultDataMask: {},
cascadeParentIds: [],
targets: [{ column: { name: 'pool_name' }, datasetId: 2 }],
description: 'Filter scoped only to non-default tab',
},
];
const { result } = renderHook(() => useSelectFiltersInScope(filters));
expect(result.current[0]).toHaveLength(1);
expect(result.current[0][0]).toEqual(
expect.objectContaining({ id: 'filter_company' }),
);
expect(result.current[1]).toHaveLength(1);
expect(result.current[1][0]).toEqual(
expect.objectContaining({ id: 'filter_desktop_only' }),
);
});
test('useSelectFiltersInScope: dividers are always in scope even when activeTabs is empty', () => {
(useSelector as jest.Mock).mockImplementation((selector: Function) => {
const mockState = {
dashboardState: { activeTabs: [] },
dashboardLayout: { present: embeddedLayout() },
};
return selector(mockState);
});
const items: (Filter | Divider)[] = [
{
id: 'divider_embedded',
type: NativeFilterType.Divider,
title: 'Section',
description: 'Divider in embedded mode',
},
{
id: 'filter_default',
name: 'Default Tab Filter',
filterType: 'filter_select',
type: NativeFilterType.NativeFilter,
chartsInScope: [1],
scope: { rootPath: ['TAB-Company'], excluded: [] },
controlValues: {},
defaultDataMask: {},
cascadeParentIds: [],
targets: [{ column: { name: 'col' }, datasetId: 1 }],
description: 'Filter in default tab',
},
];
const { result } = renderHook(() => useSelectFiltersInScope(items));
expect(result.current[0]).toHaveLength(2);
expect(result.current[1]).toHaveLength(0);
});
test('useSelectFiltersInScope: correctly scopes chartsInScope filters when activeTabs is populated', () => {
(useSelector as jest.Mock).mockImplementation((selector: Function) => {
const mockState = {
dashboardState: { activeTabs: ['TAB-Company'] },
dashboardLayout: { present: embeddedLayout() },
};
return selector(mockState);
});
const filters: Filter[] = [
{
id: 'filter_active_tab',
name: 'Active Tab Filter',
filterType: 'filter_select',
type: NativeFilterType.NativeFilter,
chartsInScope: [1],
scope: { rootPath: ['TAB-Company'], excluded: [] },
controlValues: {},
defaultDataMask: {},
cascadeParentIds: [],
targets: [{ column: { name: 'col' }, datasetId: 1 }],
description: 'Filter scoped to active tab',
},
{
id: 'filter_inactive_tab',
name: 'Inactive Tab Filter',
filterType: 'filter_select',
type: NativeFilterType.NativeFilter,
chartsInScope: [3],
scope: { rootPath: ['TAB-Desktop'], excluded: [] },
controlValues: {},
defaultDataMask: {},
cascadeParentIds: [],
targets: [{ column: { name: 'col' }, datasetId: 2 }],
description: 'Filter scoped to inactive tab',
},
];
const { result } = renderHook(() => useSelectFiltersInScope(filters));
expect(result.current[0]).toHaveLength(1);
expect(result.current[0][0]).toEqual(
expect.objectContaining({ id: 'filter_active_tab' }),
);
expect(result.current[1]).toHaveLength(1);
expect(result.current[1][0]).toEqual(
expect.objectContaining({ id: 'filter_inactive_tab' }),
);
});
// Layout-derived default-tab fallback edge cases. These pin behavior of
// useActiveDashboardTabs when activeTabs is empty across structural variants
// of dashboardLayout, so the fallback can't silently regress.
test('useIsFilterInScope: dashboard with no top-level tabs (root child is GRID_ID) treats filters as in-scope', () => {
(useSelector as jest.Mock).mockImplementation((selector: Function) => {
const mockState = {
dashboardState: { activeTabs: [] },
dashboardLayout: {
present: {
ROOT_ID: { type: 'ROOT', id: 'ROOT_ID', children: ['GRID_ID'] },
GRID_ID: { type: 'GRID', id: 'GRID_ID', children: ['CHART-1'] },
'CHART-1': {
type: 'CHART',
meta: { chartId: 1 },
parents: ['ROOT_ID', 'GRID_ID'],
},
},
},
};
return selector(mockState);
});
const filter: Filter = {
id: 'filter_no_tabs',
name: 'No-tabs filter',
filterType: 'filter_select',
type: NativeFilterType.NativeFilter,
chartsInScope: [1],
scope: { rootPath: ['ROOT_ID'], excluded: [] },
controlValues: {},
defaultDataMask: {},
cascadeParentIds: [],
targets: [{ column: { name: 'col' }, datasetId: 1 }],
description: 'Filter on a no-tabs dashboard',
};
const { result } = renderHook(() => useIsFilterInScope());
// Chart has no TAB ancestors → tabParents is empty → considered in-scope.
expect(result.current(filter)).toBe(true);
});
test('useIsFilterInScope: missing dashboardLayout falls back without crashing', () => {
(useSelector as jest.Mock).mockImplementation((selector: Function) => {
const mockState = {
dashboardState: { activeTabs: [] },
dashboardLayout: { present: undefined },
};
return selector(mockState);
});
const filter: Filter = {
id: 'filter_no_layout',
name: 'No layout',
filterType: 'filter_select',
type: NativeFilterType.NativeFilter,
chartsInScope: [1],
scope: { rootPath: ['TAB-A'], excluded: [] },
controlValues: {},
defaultDataMask: {},
cascadeParentIds: [],
targets: [{ column: { name: 'col' }, datasetId: 1 }],
description: 'Filter when layout is missing',
};
// The hook should not throw when layout is missing. Without the
// useActiveDashboardTabs guard, indexing dashboardLayout[ROOT_ID] would
// crash here.
const { result } = renderHook(() => useIsFilterInScope());
expect(() => result.current(filter)).not.toThrow();
});
// Shared fixture for the two nested-tabs tests below. Layout is identical;
// only the redux activeTabs differs (empty for the default-path test,
// inner-only for the hideTab ancestor-merge test).
const nestedTabsLayout = () => ({
ROOT_ID: { type: 'ROOT', id: 'ROOT_ID', children: ['TABS-1'] },
'TABS-1': {
type: 'TABS',
id: 'TABS-1',
children: ['TAB-Outer1', 'TAB-Outer2'],
},
'TAB-Outer1': {
type: 'TAB',
id: 'TAB-Outer1',
children: ['TABS-2'],
},
'TAB-Outer2': {
type: 'TAB',
id: 'TAB-Outer2',
children: ['CHART-Outer2'],
},
'TABS-2': {
type: 'TABS',
id: 'TABS-2',
children: ['TAB-Inner1', 'TAB-Inner2'],
},
'TAB-Inner1': {
type: 'TAB',
id: 'TAB-Inner1',
children: ['CHART-Inner1'],
},
'TAB-Inner2': {
type: 'TAB',
id: 'TAB-Inner2',
children: ['CHART-Inner2'],
},
'CHART-Inner1': {
type: 'CHART',
meta: { chartId: 11 },
parents: ['ROOT_ID', 'TAB-Outer1', 'TABS-2', 'TAB-Inner1'],
},
'CHART-Inner2': {
type: 'CHART',
meta: { chartId: 12 },
parents: ['ROOT_ID', 'TAB-Outer1', 'TABS-2', 'TAB-Inner2'],
},
'CHART-Outer2': {
type: 'CHART',
meta: { chartId: 20 },
parents: ['ROOT_ID', 'TAB-Outer2'],
},
});
const mockNestedTabsState = (activeTabs: string[]) => ({
dashboardState: { activeTabs },
dashboardLayout: { present: nestedTabsLayout() },
});
test('useIsFilterInScope: deeply nested tabs — default path includes inner-tab default', () => {
// ROOT → TABS-1 → [TAB-Outer1, TAB-Outer2]
// └─ TAB-Outer1 → TABS-2 → [TAB-Inner1, TAB-Inner2]
// Default path should be ['TAB-Outer1', 'TAB-Inner1'].
(useSelector as jest.Mock).mockImplementation((selector: Function) =>
selector(mockNestedTabsState([])),
);
const innerDefaultFilter: Filter = {
id: 'filter_inner1',
name: 'Inner default-tab filter',
filterType: 'filter_select',
type: NativeFilterType.NativeFilter,
chartsInScope: [11],
scope: { rootPath: ['TAB-Inner1'], excluded: [] },
controlValues: {},
defaultDataMask: {},
cascadeParentIds: [],
targets: [{ column: { name: 'col' }, datasetId: 1 }],
description: 'Filter scoped to inner default tab',
};
const innerNonDefaultFilter: Filter = {
...innerDefaultFilter,
id: 'filter_inner2',
chartsInScope: [12],
scope: { rootPath: ['TAB-Inner2'], excluded: [] },
};
const { result } = renderHook(() => useIsFilterInScope());
// CHART-Inner1's tab parents [TAB-Outer1, TAB-Inner1] are both in default
// path → in scope.
expect(result.current(innerDefaultFilter)).toBe(true);
// CHART-Inner2's tab parent TAB-Inner2 is not in default path → out of scope.
expect(result.current(innerNonDefaultFilter)).toBe(false);
});
test('useIsFilterInScope: nested Tabs mounted under hideTab:true — outer ancestor merged so outer-tab scoping is preserved', () => {
// hideTab:true skips the top-level Tabs but a nested Tabs can still mount
// and dispatch setActiveTab. activeTabs holds only the inner id; without
// ancestor merging, filters whose charts have tabParents=[outer, inner]
// would be marked out-of-scope because the outer id is missing.
(useSelector as jest.Mock).mockImplementation((selector: Function) =>
selector(mockNestedTabsState(['TAB-Inner1'])),
);
const innerActiveFilter: Filter = {
id: 'filter_inner1_active',
name: 'Filter scoped to active inner tab',
filterType: 'filter_select',
type: NativeFilterType.NativeFilter,
chartsInScope: [11],
scope: { rootPath: ['TAB-Inner1'], excluded: [] },
controlValues: {},
defaultDataMask: {},
cascadeParentIds: [],
targets: [{ column: { name: 'col' }, datasetId: 1 }],
description: 'Filter on the active inner tab',
};
const otherOuterFilter: Filter = {
id: 'filter_other_outer',
name: 'Filter scoped to non-default outer tab',
filterType: 'filter_select',
type: NativeFilterType.NativeFilter,
chartsInScope: [20],
scope: { rootPath: ['TAB-Outer2'], excluded: [] },
controlValues: {},
defaultDataMask: {},
cascadeParentIds: [],
targets: [{ column: { name: 'col' }, datasetId: 2 }],
description: 'Filter on the other outer tab — must stay out of scope',
};
const { result } = renderHook(() => useIsFilterInScope());
// Outer ancestor TAB-Outer1 is merged into the active path → in scope.
expect(result.current(innerActiveFilter)).toBe(true);
// TAB-Outer2 is not in the active path → out of scope, scoping preserved.
expect(result.current(otherOuterFilter)).toBe(false);
});

View File

@@ -30,7 +30,8 @@ import {
} from '@superset-ui/core';
import { FilterElement } from './FilterBar/FilterControls/types';
import { ActiveTabs, DashboardLayout, RootState } from '../../types';
import { CHART_TYPE, TAB_TYPE } from '../../util/componentTypes';
import { CHART_TYPE, TAB_TYPE, TABS_TYPE } from '../../util/componentTypes';
import { DASHBOARD_ROOT_ID } from '../../util/constants';
import { isChartCustomizationId } from './FiltersConfigModal/utils';
import {
migrateChartCustomizationArray,
@@ -38,6 +39,7 @@ import {
} from '../../util/migrateChartCustomization';
const EMPTY_ARRAY: ChartCustomizationConfiguration = [];
const EMPTY_ACTIVE_TABS: ActiveTabs = [];
const defaultFilterConfiguration: (Filter | Divider)[] = [];
export const selectFilterConfiguration: (
@@ -175,22 +177,83 @@ export function useDashboardHasTabs() {
const dashboardLayout = useDashboardLayout();
return useMemo(
() =>
Object.values(dashboardLayout).some(element => element.type === TAB_TYPE),
dashboardLayout
? Object.values(dashboardLayout).some(
element => element.type === TAB_TYPE,
)
: false,
[dashboardLayout],
);
}
function useActiveDashboardTabs() {
return useSelector<RootState, ActiveTabs>(
function useActiveDashboardTabs(): ActiveTabs {
const reduxTabs = useSelector<RootState, ActiveTabs>(
state => state.dashboardState?.activeTabs,
);
const dashboardLayout = useDashboardLayout();
return useMemo(() => {
const reduxList = reduxTabs ?? [];
const reduxFallback = reduxList.length ? reduxList : EMPTY_ACTIVE_TABS;
if (!dashboardLayout) return reduxFallback;
// Tabbed dashboards always nest the top-level TABS container as the first
// child of ROOT. If that invariant doesn't hold (no-tabs layout), no
// fallback applies and we use reduxTabs as-is.
const root = dashboardLayout[DASHBOARD_ROOT_ID];
if (!root?.children?.length) return reduxFallback;
const topContainer = dashboardLayout[root.children[0]];
if (topContainer?.type !== TABS_TYPE || !topContainer.children?.length) {
return reduxFallback;
}
// Walk every TABS container along the active path. For each container,
// pick the child Redux marked active; otherwise pick the first child (the
// default the live Tabs component would render). This handles:
// - empty reduxTabs (hideTab:true, no permalink) → full default path
// - reduxTabs missing an outer ancestor (hideTab:true skipped the
// top-level Tabs, but a nested Tabs dispatched setActiveTab) → fill
// in the missing ancestor so outer-tab scoping is preserved
// - fully populated reduxTabs (normal hydration) → same result
const reduxSet = new Set(reduxList);
const result: ActiveTabs = [];
const queue: string[] = [
topContainer.children.find(c => reduxSet.has(c)) ??
topContainer.children[0],
];
while (queue.length > 0) {
const tabId = queue.shift()!;
result.push(tabId);
const tab = dashboardLayout[tabId];
if (!tab?.children) continue;
for (const childId of tab.children) {
const child = dashboardLayout[childId];
if (child?.type !== TABS_TYPE || !child.children?.length) continue;
queue.push(
child.children.find(c => reduxSet.has(c)) ?? child.children[0],
);
}
}
// Preserve any reduxTabs entries that fell outside the traversed path so
// we never silently drop a redux-marked active tab id.
const resultSet = new Set(result);
for (const id of reduxList) {
if (!resultSet.has(id)) result.push(id);
}
return result;
}, [reduxTabs, dashboardLayout]);
}
function useSelectChartTabParents() {
const dashboardLayout = useDashboardLayout();
const layoutChartItems = useMemo(
() =>
Object.values(dashboardLayout).filter(item => item.type === CHART_TYPE),
dashboardLayout
? Object.values(dashboardLayout).filter(
item => item.type === CHART_TYPE,
)
: [],
[dashboardLayout],
);
return useCallback(
@@ -199,7 +262,7 @@ function useSelectChartTabParents() {
layoutItem => layoutItem.meta?.chartId === chartId,
);
return chartLayoutItem?.parents?.filter(
(parent: string) => dashboardLayout[parent]?.type === TAB_TYPE,
(parent: string) => dashboardLayout?.[parent]?.type === TAB_TYPE,
);
},
[dashboardLayout, layoutChartItems],
@@ -332,6 +395,7 @@ export function useSelectCustomizationsInScope(
| ChartCustomizationDivider
)[] = [];
// we check customization scopes only on dashboards with tabs
if (!dashboardHasTabs) {
customizationsInScope = customizations;
} else {

View File

@@ -1684,9 +1684,14 @@ SMTP_USER = "superset"
SMTP_PORT = 25
SMTP_PASSWORD = "superset" # noqa: S105
SMTP_MAIL_FROM = "superset@superset.com"
# If True creates a default SSL context with ssl.Purpose.CLIENT_AUTH using the
# default system root CA certificates.
SMTP_SSL_SERVER_AUTH = False
# If True creates a default SSL context with ssl.Purpose.SERVER_AUTH using the
# default system root CA certificates. This makes STARTTLS/SSL connections to the
# SMTP server validate the server's certificate against the trusted CA store.
# Defaults to True so the mail server identity is verified out of the box. Set to
# False to restore the previous behavior of skipping certificate validation (for
# example, when using a self-signed certificate that is not in the system CA
# store).
SMTP_SSL_SERVER_AUTH = True
ENABLE_CHUNK_ENCODING = False
# Whether to bump the logging level to ERROR on the flask_appbuilder package

View File

@@ -208,6 +208,7 @@ class TestEmailSmtp(SupersetTestCase):
@mock.patch("smtplib.SMTP")
def test_send_mime_ssl(self, mock_smtp, mock_smtp_ssl):
current_app.config["SMTP_SSL"] = True
current_app.config["SMTP_SSL_SERVER_AUTH"] = False
mock_smtp.return_value = mock.Mock()
mock_smtp_ssl.return_value = mock.Mock()
utils.send_mime_email(

View File

@@ -312,3 +312,119 @@ def test_full_setting(
assert dttm_col.is_dttm
assert dttm_col.python_date_format == "epoch_s"
assert dttm_col.expression == "CAST(dttm as INTEGER)"
def test_smtp_ssl_server_auth_defaults_to_true() -> None:
"""
The shipped default for SMTP_SSL_SERVER_AUTH validates the SMTP server's
TLS certificate. Operators can still opt out by overriding it to False.
"""
from superset import config
assert config.SMTP_SSL_SERVER_AUTH is True
def _smtp_config(**overrides: Any) -> dict[str, Any]:
config = {
"SMTP_HOST": "localhost",
"SMTP_PORT": 25,
"SMTP_USER": "",
"SMTP_PASSWORD": "",
"SMTP_STARTTLS": False,
"SMTP_SSL": False,
"SMTP_SSL_SERVER_AUTH": True,
}
config.update(overrides)
return config
def test_send_mime_email_ssl_server_auth_passes_context(
mocker: MockerFixture,
) -> None:
"""
With SMTP_SSL and SMTP_SSL_SERVER_AUTH enabled, ``send_mime_email`` builds a
default SSL context and threads it through to ``smtplib.SMTP_SSL`` so the
server certificate is validated.
"""
from email.mime.multipart import MIMEMultipart
from superset.utils import core as utils
create_default_context = mocker.patch(
"superset.utils.core.ssl.create_default_context"
)
smtp_ssl = mocker.patch("smtplib.SMTP_SSL")
smtp = mocker.patch("smtplib.SMTP")
utils.send_mime_email(
"from",
["to"],
MIMEMultipart(),
_smtp_config(SMTP_SSL=True, SMTP_SSL_SERVER_AUTH=True),
dryrun=False,
)
create_default_context.assert_called_once_with()
assert not smtp.called
smtp_ssl.assert_called_once_with(
"localhost", 25, context=create_default_context.return_value
)
def test_send_mime_email_starttls_server_auth_passes_context(
mocker: MockerFixture,
) -> None:
"""
With STARTTLS and SMTP_SSL_SERVER_AUTH enabled, ``send_mime_email`` builds a
default SSL context and threads it through to ``starttls`` so the server
certificate is validated.
"""
from email.mime.multipart import MIMEMultipart
from superset.utils import core as utils
create_default_context = mocker.patch(
"superset.utils.core.ssl.create_default_context"
)
smtp = mocker.patch("smtplib.SMTP")
utils.send_mime_email(
"from",
["to"],
MIMEMultipart(),
_smtp_config(SMTP_STARTTLS=True, SMTP_SSL_SERVER_AUTH=True),
dryrun=False,
)
create_default_context.assert_called_once_with()
smtp.return_value.starttls.assert_called_once_with(
context=create_default_context.return_value
)
def test_send_mime_email_server_auth_disabled_skips_context(
mocker: MockerFixture,
) -> None:
"""
When SMTP_SSL_SERVER_AUTH is disabled no SSL context is built and ``None`` is
passed through, preserving the opt-out (certificate validation skipped).
"""
from email.mime.multipart import MIMEMultipart
from superset.utils import core as utils
create_default_context = mocker.patch(
"superset.utils.core.ssl.create_default_context"
)
smtp_ssl = mocker.patch("smtplib.SMTP_SSL")
utils.send_mime_email(
"from",
["to"],
MIMEMultipart(),
_smtp_config(SMTP_SSL=True, SMTP_SSL_SERVER_AUTH=False),
dryrun=False,
)
assert not create_default_context.called
smtp_ssl.assert_called_once_with("localhost", 25, context=None)