mirror of
https://github.com/apache/superset.git
synced 2026-06-14 03:59:22 +00:00
Compare commits
16 Commits
ci/only-ru
...
fix/chart-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8b9e990ae | ||
|
|
1c438a57d4 | ||
|
|
b05fe4857e | ||
|
|
4a5c0d9042 | ||
|
|
daff4fd87e | ||
|
|
a8e26c254f | ||
|
|
e7337eacfc | ||
|
|
9e7e1ecdbc | ||
|
|
8ed7ebb5b7 | ||
|
|
2f008afca9 | ||
|
|
814b72c6f9 | ||
|
|
663b47aa75 | ||
|
|
9938ee273f | ||
|
|
74845eaf0b | ||
|
|
b0d7880ac0 | ||
|
|
058be4b904 |
1
.github/workflows/superset-e2e.yml
vendored
1
.github/workflows/superset-e2e.yml
vendored
@@ -52,7 +52,6 @@ jobs:
|
||||
if: needs.changes.outputs.python == 'true' || needs.changes.outputs.frontend == 'true'
|
||||
# Somehow one test flakes on 24.04 for unknown reasons, this is the only GHA left on 22.04
|
||||
runs-on: ubuntu-22.04
|
||||
if: github.event.pull_request.draft == false
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -91,6 +91,10 @@ 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.
|
||||
|
||||
### Sessions are terminated when an account is disabled
|
||||
|
||||
Disabling a user account (setting `active` to `False`, via the admin UI, REST API, or CLI) now terminates that user's outstanding sessions on their next request, instead of relying on a passive check. This works for both client-side cookie sessions and server-side session stores via a per-user invalidation epoch (`user_attribute.sessions_invalidated_at`, added by a migration). The mechanism is inert for users that were never disabled (NULL epoch), so there is no behavior change for active users. Re-enabling an account and logging in again starts a fresh, valid session. The migration backfills the epoch for accounts that are already disabled at upgrade time, so re-enabling such an account does not revive a session that predates this feature.
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -9300,9 +9300,9 @@ jiti@^1.20.0:
|
||||
integrity sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==
|
||||
|
||||
joi@^17.9.2:
|
||||
version "17.13.3"
|
||||
resolved "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz"
|
||||
integrity sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==
|
||||
version "17.13.4"
|
||||
resolved "https://registry.yarnpkg.com/joi/-/joi-17.13.4.tgz#ad6153d97ce558eb3a3b593e0d43eab51df1c474"
|
||||
integrity sha512-1RuuER6kmt8K8I3nIWvPZKi5RQCb568ZPyY4Pwjlua+yo+63ZTmIwxLZH0heBmiKN4uxjvCiarDrjaeH84xicQ==
|
||||
dependencies:
|
||||
"@hapi/hoek" "^9.3.0"
|
||||
"@hapi/topo" "^5.1.0"
|
||||
|
||||
@@ -80,7 +80,7 @@ dependencies = [
|
||||
"bottleneck", # recommended performance dependency for pandas, see https://pandas.pydata.org/docs/getting_started/install.html#performance-dependencies-recommended
|
||||
# --------------------------
|
||||
"parsedatetime",
|
||||
"paramiko>=3.4.0",
|
||||
"paramiko>=3.4.0, <4.0", # 4.0 removed DSSKey, still referenced by sshtunnel
|
||||
"pgsanity",
|
||||
"Pillow>=11.0.0, <13",
|
||||
"polyline>=2.0.0, <3.0",
|
||||
@@ -118,7 +118,7 @@ athena = ["pyathena[pandas]>=2, <4"]
|
||||
aurora-data-api = ["preset-sqlalchemy-aurora-data-api>=0.2.8,<0.3"]
|
||||
bigquery = [
|
||||
"pandas-gbq>=0.19.1",
|
||||
"sqlalchemy-bigquery>=1.15.0",
|
||||
"sqlalchemy-bigquery>=1.17.0",
|
||||
"google-cloud-bigquery>=3.10.0",
|
||||
]
|
||||
clickhouse = ["clickhouse-connect>=1.1.1, <2.0"]
|
||||
@@ -150,7 +150,7 @@ fastmcp = [
|
||||
# tiktoken backs the response-size-guard token estimator. Without
|
||||
# it, the middleware falls back to a coarser character-based
|
||||
# heuristic that under-counts JSON-heavy MCP responses.
|
||||
"tiktoken>=0.7.0,<1.0",
|
||||
"tiktoken>=0.13.0,<1.0",
|
||||
]
|
||||
firebird = ["sqlalchemy-firebird>=0.7.0, <2.2"]
|
||||
firebolt = ["firebolt-sqlalchemy>=1.0.0, <2"]
|
||||
@@ -167,7 +167,7 @@ hive = [
|
||||
impala = ["impyla>0.16.2, <0.23"]
|
||||
kusto = ["sqlalchemy-kusto>=3.1.2, <4"]
|
||||
kylin = ["kylinpy>=2.8.1, <2.9"]
|
||||
mssql = ["pymssql>=2.2.8, <3"]
|
||||
mssql = ["pymssql>=2.3.13, <3"]
|
||||
# motherduck is an alias for duckdb - MotherDuck works via the duckdb driver
|
||||
motherduck = ["apache-superset[duckdb]"]
|
||||
mysql = ["mysqlclient>=2.1.0, <3"]
|
||||
|
||||
@@ -976,7 +976,7 @@ sqlalchemy==1.4.54
|
||||
# shillelagh
|
||||
# sqlalchemy-bigquery
|
||||
# sqlalchemy-utils
|
||||
sqlalchemy-bigquery==1.15.0
|
||||
sqlalchemy-bigquery==1.17.0
|
||||
# via apache-superset
|
||||
sqlalchemy-utils==0.42.0
|
||||
# via
|
||||
@@ -1007,7 +1007,7 @@ tabulate==0.10.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
tiktoken==0.12.0
|
||||
tiktoken==0.13.0
|
||||
# via apache-superset
|
||||
tomli-w==1.2.0
|
||||
# via apache-superset-extensions-cli
|
||||
|
||||
@@ -149,6 +149,7 @@ module.exports = {
|
||||
'theme-colors/no-literal-colors': 'error',
|
||||
'icons/no-fa-icons-usage': 'error',
|
||||
'i18n-strings/no-template-vars': 'error',
|
||||
'i18n-strings/no-eager-t-in-config': 'off', // enabled only for controlPanel files via overrides below
|
||||
|
||||
// Core ESLint overrides for Superset
|
||||
'no-console': 'warn',
|
||||
@@ -262,6 +263,19 @@ module.exports = {
|
||||
],
|
||||
},
|
||||
overrides: [
|
||||
// Eager t()/tn() in `label`/`description` config props is captured at
|
||||
// module-load time, before i18n initializes — labels stay in the fallback
|
||||
// language even after the user switches. Surfaced as a warning (with
|
||||
// autofix to `() => t(...)`) wherever this is a real foot-gun:
|
||||
// controlPanel files. Many pre-existing call sites need conversion;
|
||||
// run `eslint --fix` on a controlPanel file to sweep it. Promote to
|
||||
// `'error'` once the codebase is clean.
|
||||
{
|
||||
files: ['**/controlPanel.{ts,tsx,js,jsx}'],
|
||||
rules: {
|
||||
'i18n-strings/no-eager-t-in-config': 'warn',
|
||||
},
|
||||
},
|
||||
// Ban JavaScript files in src/ - all new code must be TypeScript
|
||||
{
|
||||
files: ['src/**/*.js', 'src/**/*.jsx'],
|
||||
|
||||
@@ -65,6 +65,86 @@ const plugin: { rules: Record<string, Rule.RuleModule> } = {
|
||||
};
|
||||
},
|
||||
},
|
||||
'no-eager-t-in-config': {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
fixable: 'code',
|
||||
docs: {
|
||||
description:
|
||||
'Disallow eager t()/tn() calls for `label` and `description` in config objects evaluated at module load (e.g., controlPanel files). The translation is captured at module-evaluation time, before i18n has loaded, and never updates when the user switches language. Wrap the call in an arrow function so it is evaluated at render time.',
|
||||
},
|
||||
schema: [
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
properties: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
],
|
||||
messages: {
|
||||
eager:
|
||||
'Eager `{{property}}: {{fn}}(...)` is evaluated at module load, before i18n is initialized. Wrap in an arrow function: `{{property}}: () => {{fn}}(...)`.',
|
||||
},
|
||||
},
|
||||
create(context: Rule.RuleContext): Rule.RuleListener {
|
||||
const watchedProps: string[] = context.options[0]?.properties ?? [
|
||||
'label',
|
||||
'description',
|
||||
];
|
||||
const TRANSLATE_FNS = new Set(['t', 'tn']);
|
||||
|
||||
function handler(node: Node): void {
|
||||
const prop = node as Node & {
|
||||
key: { type: string; name?: string; value?: string };
|
||||
value: Node & {
|
||||
type: string;
|
||||
callee?: { type: string; name?: string };
|
||||
};
|
||||
shorthand?: boolean;
|
||||
computed?: boolean;
|
||||
};
|
||||
if (prop.shorthand || prop.computed) return;
|
||||
|
||||
const keyName =
|
||||
prop.key.type === 'Identifier'
|
||||
? prop.key.name
|
||||
: prop.key.type === 'Literal'
|
||||
? prop.key.value
|
||||
: undefined;
|
||||
if (typeof keyName !== 'string' || !watchedProps.includes(keyName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const callee = prop.value;
|
||||
if (
|
||||
callee.type !== 'CallExpression' ||
|
||||
callee.callee?.type !== 'Identifier' ||
|
||||
!callee.callee.name ||
|
||||
!TRANSLATE_FNS.has(callee.callee.name)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
context.report({
|
||||
node: prop.value,
|
||||
messageId: 'eager',
|
||||
data: { property: keyName, fn: callee.callee.name },
|
||||
fix(fixer) {
|
||||
const source = context.getSourceCode().getText(prop.value);
|
||||
return fixer.replaceText(prop.value, `() => ${source}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
Property: handler,
|
||||
};
|
||||
},
|
||||
},
|
||||
'sentence-case-buttons': {
|
||||
meta: {
|
||||
type: 'suggestion',
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* 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 type { Rule } from 'eslint';
|
||||
|
||||
const { RuleTester } = require('eslint');
|
||||
const plugin: { rules: Record<string, Rule.RuleModule> } = require('.');
|
||||
|
||||
const ruleTester = new RuleTester({
|
||||
parserOptions: { ecmaVersion: 2020, sourceType: 'module' },
|
||||
});
|
||||
const rule: Rule.RuleModule = plugin.rules['no-eager-t-in-config'];
|
||||
|
||||
ruleTester.run('no-eager-t-in-config', rule, {
|
||||
valid: [
|
||||
// Lazy form — the recommended pattern
|
||||
"const c = { label: () => t('Foo') };",
|
||||
"const c = { description: () => t('Foo') };",
|
||||
"const c = { label: () => tn('one', 'many', n) };",
|
||||
// Static strings — no translation, no issue
|
||||
"const c = { label: 'Foo' };",
|
||||
// Other property names — unaffected
|
||||
"const c = { name: t('Foo') };",
|
||||
"const c = { title: t('Foo') };",
|
||||
// Computed keys are too dynamic to lint usefully
|
||||
"const c = { [labelKey]: t('Foo') };",
|
||||
// Shorthand: `{ label }` — no value to inspect
|
||||
'const label = t("Foo"); const c = { label };',
|
||||
// t() called inside a function body — already lazy
|
||||
"const c = { label: state => t('Foo') };",
|
||||
// Non-t() call expressions are fine
|
||||
"const c = { label: someOtherFn('Foo') };",
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
code: "const c = { label: t('Foo') };",
|
||||
output: "const c = { label: () => t('Foo') };",
|
||||
errors: [{ messageId: 'eager' }],
|
||||
},
|
||||
{
|
||||
code: "const c = { description: t('Foo bar') };",
|
||||
output: "const c = { description: () => t('Foo bar') };",
|
||||
errors: [{ messageId: 'eager' }],
|
||||
},
|
||||
{
|
||||
code: "const c = { label: tn('one', 'many', 2) };",
|
||||
output: "const c = { label: () => tn('one', 'many', 2) };",
|
||||
errors: [{ messageId: 'eager' }],
|
||||
},
|
||||
// String-literal keys are equivalent to identifier keys
|
||||
{
|
||||
code: "const c = { 'label': t('Foo') };",
|
||||
output: "const c = { 'label': () => t('Foo') };",
|
||||
errors: [{ messageId: 'eager' }],
|
||||
},
|
||||
// Custom watched-property list via rule option
|
||||
{
|
||||
code: "const c = { headerTitle: t('Foo') };",
|
||||
output: "const c = { headerTitle: () => t('Foo') };",
|
||||
options: [{ properties: ['headerTitle'] }],
|
||||
errors: [{ messageId: 'eager' }],
|
||||
},
|
||||
// Nested config — fires per occurrence
|
||||
{
|
||||
code: "const c = { foo: { label: t('A'), description: t('B') } };",
|
||||
output:
|
||||
"const c = { foo: { label: () => t('A'), description: () => t('B') } };",
|
||||
errors: [{ messageId: 'eager' }, { messageId: 'eager' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -26,7 +26,7 @@ test('t() warns and creates a default translator when called before configure',
|
||||
const { t } = require('./TranslatorSingleton');
|
||||
const result = t('hello');
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'You should call configure(...) before calling other methods',
|
||||
expect.stringMatching(/was called before configure\(\)/),
|
||||
);
|
||||
expect(result).toBe('hello');
|
||||
consoleSpy.mockRestore();
|
||||
@@ -54,7 +54,7 @@ test('resetTranslation resets the configured singleton', () => {
|
||||
// After reset, calling t() should warn again
|
||||
t('hello');
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'You should call configure(...) before calling other methods',
|
||||
expect.stringMatching(/was called before configure\(\)/),
|
||||
);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
@@ -96,6 +96,69 @@ test('tn() calls translateWithNumber on the singleton', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('pre-configure warning fires once per unique key', () => {
|
||||
jest.isolateModules(() => {
|
||||
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const { t } = require('./TranslatorSingleton');
|
||||
t('apple');
|
||||
t('apple');
|
||||
t('apple');
|
||||
t('banana');
|
||||
expect(consoleSpy).toHaveBeenCalledTimes(2);
|
||||
expect(consoleSpy).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.stringContaining('"apple"'),
|
||||
);
|
||||
expect(consoleSpy).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.stringContaining('"banana"'),
|
||||
);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
test('pre-configure warning suggests the lazy-function fix', () => {
|
||||
jest.isolateModules(() => {
|
||||
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const { t } = require('./TranslatorSingleton');
|
||||
t('Sort ascending');
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('() => t("Sort ascending")'),
|
||||
);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
test('pre-configure warning is suppressed in production', () => {
|
||||
jest.isolateModules(() => {
|
||||
const originalEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = 'production';
|
||||
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const { t } = require('./TranslatorSingleton');
|
||||
t('hello');
|
||||
expect(consoleSpy).not.toHaveBeenCalled();
|
||||
consoleSpy.mockRestore();
|
||||
if (originalEnv !== undefined) {
|
||||
process.env.NODE_ENV = originalEnv;
|
||||
} else {
|
||||
delete process.env.NODE_ENV;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('resetTranslation clears the warned-keys dedupe set', () => {
|
||||
jest.isolateModules(() => {
|
||||
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const { t, resetTranslation } = require('./TranslatorSingleton');
|
||||
t('hello');
|
||||
expect(consoleSpy).toHaveBeenCalledTimes(1);
|
||||
resetTranslation();
|
||||
t('hello');
|
||||
expect(consoleSpy).toHaveBeenCalledTimes(2);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
test('resetTranslation does nothing when not yet configured', () => {
|
||||
jest.isolateModules(() => {
|
||||
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
@@ -105,7 +168,7 @@ test('resetTranslation does nothing when not yet configured', () => {
|
||||
// The singleton is still unconfigured, so t() warns
|
||||
t('hello');
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'You should call configure(...) before calling other methods',
|
||||
expect.stringMatching(/was called before configure\(\)/),
|
||||
);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
@@ -25,6 +25,10 @@ import { TranslatorConfig, Translations, LocaleData } from './types';
|
||||
let singleton: Translator | undefined;
|
||||
let isConfigured = false;
|
||||
|
||||
// Tracks which keys have already triggered a pre-configure warning so the
|
||||
// logs don't drown in repeated calls from large module-load fan-outs.
|
||||
const warnedPreConfigureKeys = new Set<string>();
|
||||
|
||||
function configure(config?: TranslatorConfig) {
|
||||
singleton = new Translator(config);
|
||||
isConfigured = true;
|
||||
@@ -33,10 +37,6 @@ function configure(config?: TranslatorConfig) {
|
||||
}
|
||||
|
||||
function getInstance() {
|
||||
if (!isConfigured) {
|
||||
console.warn('You should call configure(...) before calling other methods');
|
||||
}
|
||||
|
||||
if (typeof singleton === 'undefined') {
|
||||
singleton = new Translator();
|
||||
}
|
||||
@@ -44,11 +44,32 @@ function getInstance() {
|
||||
return singleton;
|
||||
}
|
||||
|
||||
function warnPreConfigure(fn: 't' | 'tn', key: string) {
|
||||
// Only warn in non-production builds — production callers may legitimately
|
||||
// tolerate the fallback, and the noise isn't useful at runtime.
|
||||
if (
|
||||
typeof process !== 'undefined' &&
|
||||
process.env?.NODE_ENV === 'production'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (warnedPreConfigureKeys.has(key)) return;
|
||||
warnedPreConfigureKeys.add(key);
|
||||
console.warn(
|
||||
`[i18n] ${fn}(${JSON.stringify(key)}) was called before configure() — ` +
|
||||
`the result is the fallback language and will not update when the ` +
|
||||
`user switches language. If this call is at module load (e.g., a ` +
|
||||
`controlPanel \`label\`/\`description\`), wrap it in an arrow ` +
|
||||
`function: \`() => ${fn}(${JSON.stringify(key)})\`.`,
|
||||
);
|
||||
}
|
||||
|
||||
function resetTranslation() {
|
||||
if (isConfigured) {
|
||||
isConfigured = false;
|
||||
singleton = undefined;
|
||||
}
|
||||
warnedPreConfigureKeys.clear();
|
||||
}
|
||||
|
||||
function addTranslation(key: string, translations: string[]) {
|
||||
@@ -64,10 +85,12 @@ function addLocaleData(data: LocaleData) {
|
||||
}
|
||||
|
||||
function t(input: string, ...args: unknown[]) {
|
||||
if (!isConfigured) warnPreConfigure('t', input);
|
||||
return getInstance().translate(input, ...args);
|
||||
}
|
||||
|
||||
function tn(key: string, ...args: unknown[]) {
|
||||
if (!isConfigured) warnPreConfigure('tn', key);
|
||||
return getInstance().translateWithNumber(key, ...args);
|
||||
}
|
||||
|
||||
|
||||
@@ -204,8 +204,14 @@ export type TabOverride = 'data' | 'customize' | 'matrixify' | boolean;
|
||||
* these configs will be passed to the UI component for control as props.
|
||||
*
|
||||
* - type: the control type, referencing a React component of the same name
|
||||
* - label: the label as shown in the control's header
|
||||
* - description: shown in the info tooltip of the control's header
|
||||
* - label: the label as shown in the control's header. When the value involves
|
||||
* `t()`/`tn()`, prefer the arrow-function form (`label: () => t('Foo')`) so
|
||||
* the lookup runs at render time rather than at module load — eager
|
||||
* `label: t('Foo')` captures the fallback language before i18n initializes
|
||||
* and does not update on runtime language change. The
|
||||
* `i18n-strings/no-eager-t-in-config` lint rule autofixes this.
|
||||
* - description: shown in the info tooltip of the control's header. Same
|
||||
* lazy-form guidance as `label`.
|
||||
* - default: the default value when opening a new chart, or changing visualization type
|
||||
* - renderTrigger: a bool that defines whether the visualization should be re-rendered
|
||||
* when changed. This should `true` for controls that only affect the rendering (client side)
|
||||
|
||||
@@ -74,6 +74,30 @@ test('bootstrap data helper parses document data safely', () => {
|
||||
expect(getBootstrapDataFromDocument()).toBeUndefined();
|
||||
});
|
||||
|
||||
test('bootstrap data helper returns undefined without a document', () => {
|
||||
// jsdom defines `document` as a non-configurable global, so the SSR guard
|
||||
// cannot be exercised by deleting it. Instead, re-evaluate the function's
|
||||
// own source in a scope where `document` is shadowed with undefined. When
|
||||
// running under coverage, the source is istanbul-instrumented and references
|
||||
// its module-scoped counter, so the counter is injected to keep the guard's
|
||||
// execution attributed to mapStyles.ts.
|
||||
const source = getBootstrapDataFromDocument.toString();
|
||||
const counterName = source.match(/cov_\w+/)?.[0] ?? 'unusedCoverageCounter';
|
||||
const coverage = (globalThis as { __coverage__?: Record<string, unknown> })
|
||||
.__coverage__;
|
||||
const coverageEntry =
|
||||
coverage?.[
|
||||
Object.keys(coverage).find(file => file.endsWith('mapStyles.ts')) ?? ''
|
||||
];
|
||||
// eslint-disable-next-line no-new-func
|
||||
const callWithoutDocument = new Function(
|
||||
counterName,
|
||||
'document',
|
||||
`return (${source})();`,
|
||||
);
|
||||
expect(callWithoutDocument(() => coverageEntry, undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('renderer options enable Mapbox only when a key is available', () => {
|
||||
expect(getMapRendererOptions({ hasMapboxKey: true })).toEqual([
|
||||
{ value: 'maplibre' },
|
||||
@@ -233,6 +257,17 @@ test('custom raster tile templates do not receive OSM attribution', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('relative raster tile templates do not receive OSM attribution', () => {
|
||||
const relativeTileUrl = '/tiles/{z}/{x}/{y}.png';
|
||||
const style = resolveMapStyle(relativeTileUrl, 'default-style.json');
|
||||
|
||||
expect(typeof style).toBe('object');
|
||||
if (typeof style !== 'string') {
|
||||
expect(style.sources['osm-raster-tiles'].tiles).toEqual([relativeTileUrl]);
|
||||
expect(style.sources['osm-raster-tiles']).not.toHaveProperty('attribution');
|
||||
}
|
||||
});
|
||||
|
||||
test('lookalike OpenStreetMap hostnames do not receive OSM attribution', () => {
|
||||
const lookalikeTileUrl =
|
||||
'https://openstreetmap.org.example.com/{z}/{x}/{y}.png';
|
||||
|
||||
@@ -197,6 +197,62 @@ function checkI18nTemplates(ast, filepath) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for eager t()/tn() calls in `label` / `description` properties of
|
||||
* config objects evaluated at module load (e.g., controlPanel files). The
|
||||
* translation is captured at module-evaluation time, before i18n has loaded,
|
||||
* and never updates when the user switches language. The fix is to wrap the
|
||||
* call in an arrow function: `label: () => t('Foo')`.
|
||||
*
|
||||
* Limited to controlPanel files because that's where this pattern is
|
||||
* problematic at scale; t() inside JSX or component bodies is evaluated at
|
||||
* render time and works fine.
|
||||
*/
|
||||
const EAGER_T_WATCHED_PROPS = new Set(['label', 'description']);
|
||||
|
||||
function checkEagerTranslationsInConfig(ast, filepath) {
|
||||
if (!/controlPanel\.(ts|tsx|js|jsx)$/.test(filepath)) return;
|
||||
|
||||
traverse(ast, {
|
||||
ObjectProperty(path) {
|
||||
const { node } = path;
|
||||
if (node.computed || node.shorthand) return;
|
||||
|
||||
const keyName =
|
||||
node.key.type === 'Identifier'
|
||||
? node.key.name
|
||||
: node.key.type === 'StringLiteral'
|
||||
? node.key.value
|
||||
: null;
|
||||
if (!keyName || !EAGER_T_WATCHED_PROPS.has(keyName)) return;
|
||||
|
||||
const { value } = node;
|
||||
if (
|
||||
value.type !== 'CallExpression' ||
|
||||
value.callee.type !== 'Identifier' ||
|
||||
(value.callee.name !== 't' && value.callee.name !== 'tn')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasEslintDisable(path, 'i18n-strings/no-eager-t-in-config')) return;
|
||||
|
||||
// Warn (not error) because there are many pre-existing violations.
|
||||
// The ESLint plugin provides an autofix so authors can sweep files
|
||||
// as they touch them. Promote to error once the codebase is clean.
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`${YELLOW}⚠${RESET} ${filepath}:${node.loc?.start.line ?? '?'}: ` +
|
||||
`Eager \`${keyName}: ${value.callee.name}(...)\` is evaluated at ` +
|
||||
`module load, before i18n is initialized. Wrap in an arrow ` +
|
||||
`function: \`${keyName}: () => ${value.callee.name}(...)\`. ` +
|
||||
`Run \`eslint --fix\` to autofix.`,
|
||||
);
|
||||
warningCount += 1;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Props that should contain translated strings
|
||||
*/
|
||||
@@ -565,6 +621,7 @@ function processFile(filepath) {
|
||||
checkNoLiteralColors(ast, filepath);
|
||||
checkNoFaIcons(ast, filepath);
|
||||
checkI18nTemplates(ast, filepath);
|
||||
checkEagerTranslationsInConfig(ast, filepath);
|
||||
checkUntranslatedStrings(ast, filepath);
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
|
||||
14
superset-websocket/package-lock.json
generated
14
superset-websocket/package-lock.json
generated
@@ -11,7 +11,7 @@
|
||||
"dependencies": {
|
||||
"cookie": "^1.1.1",
|
||||
"hot-shots": "^15.0.0",
|
||||
"ioredis": "^5.11.0",
|
||||
"ioredis": "^5.11.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"lodash": "^4.18.1",
|
||||
"winston": "^3.19.0",
|
||||
@@ -3545,9 +3545,9 @@
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
|
||||
},
|
||||
"node_modules/ioredis": {
|
||||
"version": "5.11.0",
|
||||
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.11.0.tgz",
|
||||
"integrity": "sha512-EZBErytyVovD8f6pDfG3Kb37N6Y3lmDA9NNj+4+IP13CzzHGeX+OyeRM2Um13khRzoBSzzL+5lVnCX8V2RLeMg==",
|
||||
"version": "5.11.1",
|
||||
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.11.1.tgz",
|
||||
"integrity": "sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ioredis/commands": "1.10.0",
|
||||
@@ -9077,9 +9077,9 @@
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
|
||||
},
|
||||
"ioredis": {
|
||||
"version": "5.11.0",
|
||||
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.11.0.tgz",
|
||||
"integrity": "sha512-EZBErytyVovD8f6pDfG3Kb37N6Y3lmDA9NNj+4+IP13CzzHGeX+OyeRM2Um13khRzoBSzzL+5lVnCX8V2RLeMg==",
|
||||
"version": "5.11.1",
|
||||
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.11.1.tgz",
|
||||
"integrity": "sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==",
|
||||
"requires": {
|
||||
"@ioredis/commands": "1.10.0",
|
||||
"cluster-key-slot": "1.1.1",
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"dependencies": {
|
||||
"cookie": "^1.1.1",
|
||||
"hot-shots": "^15.0.0",
|
||||
"ioredis": "^5.11.0",
|
||||
"ioredis": "^5.11.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"lodash": "^4.18.1",
|
||||
"winston": "^3.19.0",
|
||||
|
||||
@@ -103,6 +103,19 @@ class DatasourceTypeUpdateRequiredValidationError(ValidationError):
|
||||
)
|
||||
|
||||
|
||||
class ChartQueryContextDatasourceMismatchValidationError(ValidationError):
|
||||
"""
|
||||
Raised when a query-context-only update carries a datasource that does not
|
||||
match the chart's own datasource.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
_("The query context datasource does not match the chart datasource"),
|
||||
field_name="query_context",
|
||||
)
|
||||
|
||||
|
||||
class ChartNotFoundError(CommandException):
|
||||
message = "Chart not found."
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ from superset.commands.chart.exceptions import (
|
||||
ChartForbiddenError,
|
||||
ChartInvalidError,
|
||||
ChartNotFoundError,
|
||||
ChartQueryContextDatasourceMismatchValidationError,
|
||||
ChartUpdateFailedError,
|
||||
DashboardsForbiddenError,
|
||||
DashboardsNotFoundValidationError,
|
||||
@@ -41,6 +42,7 @@ from superset.exceptions import SupersetSecurityException
|
||||
from superset.models.dashboard import Dashboard
|
||||
from superset.models.slice import Slice
|
||||
from superset.tags.models import ObjectType
|
||||
from superset.utils import json
|
||||
from superset.utils.decorators import on_error, transaction
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -101,6 +103,52 @@ class UpdateChartCommand(UpdateMixin, BaseCommand):
|
||||
if not security_manager.is_owner(dash):
|
||||
raise DashboardsForbiddenError()
|
||||
|
||||
def _validate_query_context_datasource(
|
||||
self, exceptions: list[ValidationError]
|
||||
) -> None:
|
||||
"""
|
||||
Ensure a query-context-only update keeps the chart's own datasource.
|
||||
|
||||
The submitted query context is only verified when it carries a parseable
|
||||
``datasource`` object; a payload that references a different datasource than
|
||||
the chart's persisted one is rejected. Payloads without a datasource fall
|
||||
back to the chart's datasource at execution time and need no check.
|
||||
"""
|
||||
if not self._model:
|
||||
return
|
||||
|
||||
raw_query_context = self._properties.get("query_context")
|
||||
if not raw_query_context:
|
||||
return
|
||||
|
||||
try:
|
||||
query_context = json.loads(raw_query_context)
|
||||
except (TypeError, ValueError):
|
||||
# An unparseable payload cannot be verified or replayed; leave it for
|
||||
# downstream handling rather than guessing at its intent.
|
||||
return
|
||||
|
||||
datasource = (
|
||||
query_context.get("datasource") if isinstance(query_context, dict) else None
|
||||
)
|
||||
if not isinstance(datasource, dict):
|
||||
return
|
||||
|
||||
try:
|
||||
ids_match = int(datasource["id"]) == self._model.datasource_id
|
||||
except (KeyError, TypeError, ValueError):
|
||||
ids_match = False
|
||||
|
||||
# A datasource object must carry a type that matches the chart's own.
|
||||
# Treating a missing type as valid would let an id-only payload through,
|
||||
# and query-context loading reads datasource["type"] directly, so that
|
||||
# payload raises KeyError when the saved context is later replayed.
|
||||
datasource_type = datasource.get("type")
|
||||
types_match = str(datasource_type) == self._model.datasource_type
|
||||
|
||||
if not ids_match or not types_match:
|
||||
exceptions.append(ChartQueryContextDatasourceMismatchValidationError())
|
||||
|
||||
def validate(self) -> None: # noqa: C901
|
||||
exceptions: list[ValidationError] = []
|
||||
dashboard_ids = self._properties.get("dashboards")
|
||||
@@ -120,8 +168,13 @@ class UpdateChartCommand(UpdateMixin, BaseCommand):
|
||||
if not self._model:
|
||||
raise ChartNotFoundError()
|
||||
|
||||
# Check and update ownership; when only updating query context we ignore
|
||||
# ownership so the update can be performed by report workers
|
||||
# Check and update ownership; when only updating query context we relax
|
||||
# the ownership requirement so that non-owners (report workers and any
|
||||
# viewer whose UI lazily backfills a missing query_context) can perform
|
||||
# the update. We still require access to the chart in that case, so a
|
||||
# user cannot rewrite the query_context of a chart they cannot access
|
||||
# (raise_for_access permits admins, owners, and users with access to the
|
||||
# chart's datasource).
|
||||
if not is_query_context_update(self._properties):
|
||||
try:
|
||||
security_manager.raise_for_ownership(self._model)
|
||||
@@ -134,6 +187,14 @@ class UpdateChartCommand(UpdateMixin, BaseCommand):
|
||||
raise ChartForbiddenError() from ex
|
||||
except ValidationError as ex:
|
||||
exceptions.append(ex)
|
||||
else:
|
||||
try:
|
||||
security_manager.raise_for_access(chart=self._model)
|
||||
except SupersetSecurityException as ex:
|
||||
raise ChartForbiddenError() from ex
|
||||
# Keep the refreshed payload bound to the chart's own datasource so it
|
||||
# cannot be repointed at an unrelated one.
|
||||
self._validate_query_context_datasource(exceptions)
|
||||
|
||||
# validate tags
|
||||
try:
|
||||
|
||||
@@ -362,6 +362,11 @@ RATELIMIT_ENABLED = os.environ.get("SUPERSET_ENV") == "production"
|
||||
RATELIMIT_APPLICATION = "50 per second"
|
||||
AUTH_RATE_LIMITED = True
|
||||
AUTH_RATE_LIMIT = "5 per second"
|
||||
|
||||
# When enabled, users whose account is flagged with ``password_must_change``
|
||||
# (e.g. accounts provisioned by an administrator) are redirected to the
|
||||
# password-reset page until they set a new password. Off by default.
|
||||
ENABLE_FORCE_PASSWORD_CHANGE = False
|
||||
# A storage location conforming to the scheme in storage-scheme. See the limits
|
||||
# library for allowed values: https://limits.readthedocs.io/en/stable/storage.html
|
||||
# RATELIMIT_STORAGE_URI = "redis://host:port"
|
||||
|
||||
@@ -729,6 +729,14 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
|
||||
"""Register app-level request handlers"""
|
||||
from flask import request, Response
|
||||
|
||||
from superset.security.password_change import (
|
||||
register_password_change_enforcement,
|
||||
)
|
||||
|
||||
# Redirect users with a pending forced password change to the reset
|
||||
# page (no-op unless ENABLE_FORCE_PASSWORD_CHANGE is enabled).
|
||||
register_password_change_enforcement(self.superset_app)
|
||||
|
||||
@self.superset_app.after_request
|
||||
def apply_http_headers(response: Response) -> Response:
|
||||
"""Applies the configuration's http headers to all responses"""
|
||||
@@ -764,6 +772,23 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
|
||||
gc.collect()
|
||||
return response
|
||||
|
||||
@self.superset_app.before_request
|
||||
def enforce_session_validity() -> Any:
|
||||
"""Force logout of sessions invalidated by a per-user epoch."""
|
||||
from superset.security.session_invalidation import (
|
||||
enforce_session_validity as _enforce,
|
||||
)
|
||||
|
||||
return _enforce()
|
||||
|
||||
# Stamp the per-user invalidation epoch when an account is disabled,
|
||||
# so outstanding sessions are terminated on their next request.
|
||||
from superset.security.session_invalidation import (
|
||||
register_session_invalidation_events,
|
||||
)
|
||||
|
||||
register_session_invalidation_events(appbuilder.sm.user_model)
|
||||
|
||||
@self.superset_app.context_processor
|
||||
def get_common_bootstrap_data() -> dict[str, Any]:
|
||||
# Import here to avoid circular imports
|
||||
|
||||
@@ -1956,6 +1956,15 @@ class UpdateChartRequest(QueryCacheControl):
|
||||
max_length=255,
|
||||
validation_alias=AliasChoices("chart_name", "name", "title", "slice_name"),
|
||||
)
|
||||
dataset_id: int | None = Field(
|
||||
None,
|
||||
description=(
|
||||
"Target dataset ID to rebind the chart to a different dataset. "
|
||||
"When omitted, the chart retains its existing dataset. "
|
||||
"Can be combined with config to simultaneously change the dataset "
|
||||
"and visualization, or used alone to rebind without altering the config."
|
||||
),
|
||||
)
|
||||
generate_preview: bool = Field(
|
||||
default=True,
|
||||
description=(
|
||||
|
||||
@@ -77,10 +77,11 @@ def _validation_error_response(message: str, details: str) -> GenerateChartRespo
|
||||
|
||||
def _missing_config_or_name_error() -> GenerateChartResponse:
|
||||
return _validation_error_response(
|
||||
message="Either 'config' or 'chart_name' must be provided.",
|
||||
message="Either 'config', 'chart_name', or 'dataset_id' must be provided.",
|
||||
details=(
|
||||
"Either 'config' or 'chart_name' must be provided. "
|
||||
"Use config for visualization changes, chart_name for renaming."
|
||||
"Either 'config', 'chart_name', or 'dataset_id' must be provided. "
|
||||
"Use config for visualization changes, chart_name for renaming, "
|
||||
"dataset_id to rebind the chart to a different dataset."
|
||||
),
|
||||
)
|
||||
|
||||
@@ -102,12 +103,19 @@ def _build_update_payload(
|
||||
"""Build the update payload for a chart update.
|
||||
|
||||
Returns a dict payload on success, or a GenerateChartResponse error
|
||||
when neither config nor chart_name is provided.
|
||||
when neither config nor chart_name nor dataset_id is provided.
|
||||
``parsed_config`` is the pre-parsed chart config from the caller.
|
||||
"""
|
||||
effective_dataset_id = (
|
||||
request.dataset_id
|
||||
if request.dataset_id is not None
|
||||
else (chart.datasource_id if chart.datasource_id else None)
|
||||
)
|
||||
|
||||
if parsed_config is not None:
|
||||
dataset_id = chart.datasource_id if chart.datasource_id else None
|
||||
new_form_data = map_config_to_form_data(parsed_config, dataset_id=dataset_id)
|
||||
new_form_data = map_config_to_form_data(
|
||||
parsed_config, dataset_id=effective_dataset_id
|
||||
)
|
||||
new_form_data.pop("_mcp_warnings", None)
|
||||
|
||||
chart_name = (
|
||||
@@ -116,13 +124,27 @@ def _build_update_payload(
|
||||
else chart.slice_name or generate_chart_name(parsed_config)
|
||||
)
|
||||
|
||||
return {
|
||||
payload: dict[str, Any] = {
|
||||
"slice_name": chart_name,
|
||||
"viz_type": new_form_data["viz_type"],
|
||||
"params": json.dumps(new_form_data),
|
||||
# Clear stale query_context so get_chart_data uses the updated params.
|
||||
"query_context": None,
|
||||
}
|
||||
if request.dataset_id is not None:
|
||||
payload["datasource_id"] = request.dataset_id
|
||||
payload["datasource_type"] = "table"
|
||||
return payload
|
||||
|
||||
# Dataset-only update: rebind chart to a different dataset without changing viz
|
||||
if request.dataset_id is not None:
|
||||
payload = {
|
||||
"datasource_id": request.dataset_id,
|
||||
"datasource_type": "table",
|
||||
}
|
||||
if request.chart_name:
|
||||
payload["slice_name"] = request.chart_name
|
||||
return payload
|
||||
|
||||
# Name-only update: keep existing visualization, just rename
|
||||
if not request.chart_name:
|
||||
@@ -152,13 +174,20 @@ def _build_preview_form_data(
|
||||
)
|
||||
existing_form_data = {}
|
||||
|
||||
effective_dataset_id = (
|
||||
request.dataset_id
|
||||
if request.dataset_id is not None
|
||||
else (chart.datasource_id if chart.datasource_id else None)
|
||||
)
|
||||
|
||||
if parsed_config is not None:
|
||||
dataset_id = chart.datasource_id if chart.datasource_id else None
|
||||
new_form_data = map_config_to_form_data(parsed_config, dataset_id=dataset_id)
|
||||
new_form_data = map_config_to_form_data(
|
||||
parsed_config, dataset_id=effective_dataset_id
|
||||
)
|
||||
new_form_data.pop("_mcp_warnings", None)
|
||||
merged = {**existing_form_data, **new_form_data}
|
||||
else:
|
||||
if not request.chart_name:
|
||||
if not request.chart_name and request.dataset_id is None:
|
||||
return _missing_config_or_name_error()
|
||||
merged = dict(existing_form_data)
|
||||
|
||||
@@ -168,8 +197,8 @@ def _build_preview_form_data(
|
||||
merged["slice_name"] = chart.slice_name
|
||||
|
||||
merged["slice_id"] = chart.id
|
||||
if chart.datasource_id:
|
||||
merged["datasource"] = f"{chart.datasource_id}__table"
|
||||
if effective_dataset_id:
|
||||
merged["datasource"] = f"{effective_dataset_id}__table"
|
||||
|
||||
return merged
|
||||
|
||||
@@ -178,27 +207,39 @@ def _validate_update_against_dataset(
|
||||
parsed_config: Any,
|
||||
form_data: dict[str, Any],
|
||||
chart: Any,
|
||||
dataset_id: int | None = None,
|
||||
run_compile_check: bool = True,
|
||||
) -> GenerateChartResponse | None:
|
||||
"""Run Tier 1 (schema) + Tier 2 (compile) validation against the chart's
|
||||
dataset. Returns ``None`` on success, or a :class:`GenerateChartResponse`
|
||||
error envelope on failure that callers should return as-is.
|
||||
|
||||
When ``dataset_id`` is provided, validates against that dataset instead of
|
||||
the chart's existing datasource (used when rebinding to a new dataset).
|
||||
Pass ``run_compile_check=False`` to skip the Tier 2 live-query check (used
|
||||
for dataset-only rebinds where no new chart config is provided).
|
||||
"""
|
||||
from superset.daos.dataset import DatasetDAO
|
||||
|
||||
dataset = getattr(chart, "datasource", None)
|
||||
if dataset is None and getattr(chart, "datasource_id", None) is not None:
|
||||
dataset = DatasetDAO.find_by_id(chart.datasource_id)
|
||||
if dataset_id is not None:
|
||||
dataset = DatasetDAO.find_by_id(dataset_id)
|
||||
else:
|
||||
dataset = getattr(chart, "datasource", None)
|
||||
if dataset is None and getattr(chart, "datasource_id", None) is not None:
|
||||
dataset = DatasetDAO.find_by_id(chart.datasource_id)
|
||||
if dataset is None:
|
||||
missing_id = (
|
||||
dataset_id
|
||||
if dataset_id is not None
|
||||
else getattr(chart, "datasource_id", None)
|
||||
)
|
||||
return GenerateChartResponse.model_validate(
|
||||
{
|
||||
"chart": None,
|
||||
"error": {
|
||||
"error_type": "DatasetNotAccessible",
|
||||
"message": "Chart's dataset is not accessible",
|
||||
"details": (
|
||||
f"Dataset {getattr(chart, 'datasource_id', None)} "
|
||||
"is missing or inaccessible."
|
||||
),
|
||||
"message": "Dataset is not accessible",
|
||||
"details": (f"Dataset {missing_id} is missing or inaccessible."),
|
||||
},
|
||||
"success": False,
|
||||
"schema_version": "2.0",
|
||||
@@ -207,7 +248,7 @@ def _validate_update_against_dataset(
|
||||
)
|
||||
|
||||
compile_result = validate_and_compile(
|
||||
parsed_config, form_data, dataset, run_compile_check=True
|
||||
parsed_config, form_data, dataset, run_compile_check=run_compile_check
|
||||
)
|
||||
if compile_result.success:
|
||||
return None
|
||||
@@ -239,12 +280,18 @@ def _validate_update_against_dataset(
|
||||
|
||||
|
||||
def _create_preview_url(
|
||||
chart: Any, form_data: dict[str, Any]
|
||||
chart: Any,
|
||||
form_data: dict[str, Any],
|
||||
datasource_id: int | None = None,
|
||||
) -> tuple[str, str | None, list[str]]:
|
||||
"""Cache form_data and return (explore_url, form_data_key, warnings).
|
||||
|
||||
The URL includes both ``slice_id`` and ``form_data_key`` so that when the
|
||||
user clicks Save in Explore, the edits overwrite the original chart.
|
||||
|
||||
When ``datasource_id`` is provided, uses that datasource for the form_data
|
||||
cache instead of the chart's existing datasource (used when rebinding to a
|
||||
new dataset).
|
||||
"""
|
||||
from superset.commands.explore.form_data.parameters import CommandParameters
|
||||
from superset.mcp_service.commands.create_form_data import MCPCreateFormDataCommand
|
||||
@@ -252,7 +299,11 @@ def _create_preview_url(
|
||||
|
||||
base_url = get_superset_base_url()
|
||||
|
||||
if not chart.datasource_id:
|
||||
effective_datasource_id = (
|
||||
datasource_id if datasource_id is not None else chart.datasource_id
|
||||
)
|
||||
|
||||
if not effective_datasource_id:
|
||||
warning = (
|
||||
"Chart has no datasource; the preview URL shows the saved chart "
|
||||
"state, not the pending changes. Open the URL and apply the "
|
||||
@@ -267,7 +318,7 @@ def _create_preview_url(
|
||||
|
||||
cmd_params = CommandParameters(
|
||||
datasource_type=DatasourceType.TABLE,
|
||||
datasource_id=chart.datasource_id,
|
||||
datasource_id=effective_datasource_id,
|
||||
chart_id=chart.id,
|
||||
tab_id=None,
|
||||
form_data=json.dumps(form_data),
|
||||
@@ -433,12 +484,29 @@ async def update_chart( # noqa: C901
|
||||
|
||||
# Validate before persisting — catches bad column refs and runtime
|
||||
# SQL errors so we don't commit a chart that can't be queried.
|
||||
# Renames (no parsed_config) skip validation since form_data is
|
||||
# untouched.
|
||||
# Renames (no parsed_config and no dataset_id) skip validation since
|
||||
# form_data is untouched and no rebind is requested.
|
||||
if parsed_config is not None and new_form_data is not None:
|
||||
with event_logger.log_context(action="mcp.update_chart.validation"):
|
||||
validation_error = _validate_update_against_dataset(
|
||||
parsed_config, new_form_data, chart
|
||||
parsed_config,
|
||||
new_form_data,
|
||||
chart,
|
||||
dataset_id=request.dataset_id,
|
||||
)
|
||||
if validation_error is not None:
|
||||
return validation_error
|
||||
elif request.dataset_id is not None:
|
||||
# Dataset-only rebind: verify the target dataset exists before
|
||||
# writing. Skip compile check — there is no new chart config to
|
||||
# execute against the new dataset.
|
||||
with event_logger.log_context(action="mcp.update_chart.validation"):
|
||||
validation_error = _validate_update_against_dataset(
|
||||
None,
|
||||
{},
|
||||
chart,
|
||||
dataset_id=request.dataset_id,
|
||||
run_compile_check=False,
|
||||
)
|
||||
if validation_error is not None:
|
||||
return validation_error
|
||||
@@ -459,14 +527,30 @@ async def update_chart( # noqa: C901
|
||||
if parsed_config is not None:
|
||||
with event_logger.log_context(action="mcp.update_chart.validation"):
|
||||
validation_error = _validate_update_against_dataset(
|
||||
parsed_config, preview_or_error, chart
|
||||
parsed_config,
|
||||
preview_or_error,
|
||||
chart,
|
||||
dataset_id=request.dataset_id,
|
||||
)
|
||||
if validation_error is not None:
|
||||
return validation_error
|
||||
elif request.dataset_id is not None:
|
||||
# Dataset-only rebind: verify the target dataset exists before
|
||||
# caching. Skip compile check — no new config to execute.
|
||||
with event_logger.log_context(action="mcp.update_chart.validation"):
|
||||
validation_error = _validate_update_against_dataset(
|
||||
None,
|
||||
{},
|
||||
chart,
|
||||
dataset_id=request.dataset_id,
|
||||
run_compile_check=False,
|
||||
)
|
||||
if validation_error is not None:
|
||||
return validation_error
|
||||
|
||||
with event_logger.log_context(action="mcp.update_chart.preview_link"):
|
||||
explore_url, form_data_key, warnings = _create_preview_url(
|
||||
chart, preview_or_error
|
||||
chart, preview_or_error, datasource_id=request.dataset_id
|
||||
)
|
||||
|
||||
chart_for_analysis = updated_chart if saved else chart
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# 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.
|
||||
"""add password_must_change to user_attribute
|
||||
|
||||
Revision ID: b7c9d1e2f3a4
|
||||
Revises: c8d2e3f4a5b6, f7a1c93e0b21
|
||||
Create Date: 2026-06-01 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from superset.migrations.shared.utils import add_columns, drop_columns
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "b7c9d1e2f3a4"
|
||||
down_revision = ("c8d2e3f4a5b6", "f7a1c93e0b21")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
add_columns(
|
||||
"user_attribute",
|
||||
sa.Column(
|
||||
"password_must_change",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.false(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
drop_columns("user_attribute", "password_must_change")
|
||||
@@ -0,0 +1,44 @@
|
||||
# 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.
|
||||
"""add guest_token_revoked_before to embedded_dashboards
|
||||
|
||||
Revision ID: c8d2e3f4a5b6
|
||||
Revises: 31dae2559c05
|
||||
Create Date: 2026-06-01 00:10:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from superset.migrations.shared.utils import add_columns, drop_columns
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "c8d2e3f4a5b6"
|
||||
down_revision = "31dae2559c05"
|
||||
|
||||
|
||||
def upgrade():
|
||||
# Epoch seconds; guest tokens for this embedded dashboard issued (iat)
|
||||
# before this value are rejected. NULL = no revocation.
|
||||
add_columns(
|
||||
"embedded_dashboards",
|
||||
sa.Column("guest_token_revoked_before", sa.Integer(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
drop_columns("embedded_dashboards", "guest_token_revoked_before")
|
||||
@@ -0,0 +1,177 @@
|
||||
# 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.
|
||||
"""add sessions_invalidated_at to user_attribute
|
||||
|
||||
Revision ID: f7a1c93e0b21
|
||||
Revises: 31dae2559c05
|
||||
Create Date: 2026-06-02 10:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
from superset.migrations.shared.utils import (
|
||||
add_columns,
|
||||
create_index,
|
||||
drop_columns,
|
||||
drop_index,
|
||||
)
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "f7a1c93e0b21"
|
||||
down_revision = "31dae2559c05"
|
||||
|
||||
TABLE = "user_attribute"
|
||||
COLUMN = "sessions_invalidated_at"
|
||||
INDEX = "ix_user_attribute_sessions_invalidated_at"
|
||||
UQ = "uq_user_attribute_user_id"
|
||||
|
||||
|
||||
MERGE_COLUMNS = ("avatar_url", "welcome_dashboard_id", "sessions_invalidated_at")
|
||||
|
||||
|
||||
def upgrade():
|
||||
add_columns(TABLE, sa.Column(COLUMN, sa.DateTime(), nullable=True))
|
||||
create_index(TABLE, INDEX, [COLUMN])
|
||||
# The model treats ``user_attribute`` as one row per user (all readers use
|
||||
# ``extra_attributes[0]``). Enforce that invariant so the session-invalidation
|
||||
# upsert is race-safe. Collapse any pre-existing duplicates into the lowest
|
||||
# ``id`` per user before adding the unique constraint.
|
||||
#
|
||||
# The merge/de-dup is driven in Python rather than via correlated subqueries:
|
||||
# MySQL forbids referencing the UPDATE/DELETE target table in a subquery
|
||||
# (error 1093), so a portable SQL form is awkward. Reading the duplicate rows
|
||||
# and resolving the winner in Python works identically on SQLite/MySQL/Postgres.
|
||||
_dedupe_user_attributes()
|
||||
# SQLite has no ALTER ... ADD CONSTRAINT, so use batch mode (copy-and-move)
|
||||
# which all dialects support.
|
||||
with op.batch_alter_table(TABLE) as batch_op:
|
||||
batch_op.create_unique_constraint(UQ, ["user_id"])
|
||||
# Backfill an epoch for accounts that are already disabled at upgrade time.
|
||||
# Without this, a user disabled before this lands and later re-enabled would
|
||||
# have no epoch, so a pre-existing session (which also carries no recorded
|
||||
# login time) would silently revive. Stamping the epoch now means any such
|
||||
# outstanding session is treated as invalidated.
|
||||
_backfill_disabled_users()
|
||||
|
||||
|
||||
def _dedupe_user_attributes():
|
||||
"""Collapse duplicate ``user_attribute`` rows into the lowest ``id`` per user.
|
||||
|
||||
Settings stored only on a higher-``id`` duplicate are merged forward into the
|
||||
kept row (the kept row's NULL columns take the lowest-``id`` non-NULL sibling
|
||||
value) so nothing is silently lost, then the redundant rows are deleted.
|
||||
"""
|
||||
bind = op.get_bind()
|
||||
columns = ", ".join(("id", "user_id", *MERGE_COLUMNS))
|
||||
rows = bind.execute(
|
||||
sa.text(f"SELECT {columns} FROM {TABLE} ORDER BY id") # noqa: S608
|
||||
).fetchall()
|
||||
|
||||
by_user: dict[int, list] = {}
|
||||
for row in rows:
|
||||
mapping = row._mapping
|
||||
if mapping["user_id"] is None:
|
||||
continue
|
||||
by_user.setdefault(mapping["user_id"], []).append(mapping)
|
||||
|
||||
for user_rows in by_user.values():
|
||||
if len(user_rows) < 2:
|
||||
continue
|
||||
# Rows are ordered by id, so the first is the keeper.
|
||||
keeper = user_rows[0]
|
||||
duplicates = user_rows[1:]
|
||||
updates = {}
|
||||
for column in MERGE_COLUMNS:
|
||||
if keeper[column] is not None:
|
||||
continue
|
||||
for dup in duplicates:
|
||||
if dup[column] is not None:
|
||||
updates[column] = dup[column]
|
||||
break
|
||||
if updates:
|
||||
assignments = ", ".join(f"{col} = :{col}" for col in updates)
|
||||
bind.execute(
|
||||
sa.text(
|
||||
f"UPDATE {TABLE} SET {assignments} WHERE id = :id" # noqa: S608
|
||||
),
|
||||
{**updates, "id": keeper["id"]},
|
||||
)
|
||||
bind.execute(
|
||||
sa.text(f"DELETE FROM {TABLE} WHERE id = :id"), # noqa: S608
|
||||
[{"id": dup["id"]} for dup in duplicates],
|
||||
)
|
||||
|
||||
|
||||
def _backfill_disabled_users():
|
||||
"""Stamp the invalidation epoch for users that are already disabled.
|
||||
|
||||
Upserts one ``user_attribute`` row per disabled user (``ab_user.active`` is
|
||||
false) that has no epoch yet, so re-enabling such an account does not revive
|
||||
a session that predates this feature. Done in Python to stay portable across
|
||||
SQLite/MySQL/Postgres; the unique constraint added above guarantees one row
|
||||
per user, so the insert path cannot create duplicates.
|
||||
"""
|
||||
bind = op.get_bind()
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
# FAB stores ``active`` as a boolean/tinyint; some legacy rows leave it NULL,
|
||||
# which is not "explicitly disabled", so only match a false value.
|
||||
disabled_user_ids = [
|
||||
row._mapping["id"]
|
||||
for row in bind.execute(
|
||||
sa.text("SELECT id FROM ab_user WHERE active = :inactive"),
|
||||
{"inactive": False},
|
||||
).fetchall()
|
||||
]
|
||||
if not disabled_user_ids:
|
||||
return
|
||||
|
||||
existing = {
|
||||
row._mapping["user_id"]
|
||||
for row in bind.execute(
|
||||
sa.text(f"SELECT user_id FROM {TABLE}") # noqa: S608
|
||||
).fetchall()
|
||||
}
|
||||
|
||||
for user_id in disabled_user_ids:
|
||||
if user_id in existing:
|
||||
bind.execute(
|
||||
sa.text(
|
||||
f"UPDATE {TABLE} SET {COLUMN} = :now, changed_on = :now " # noqa: S608, E501
|
||||
f"WHERE user_id = :user_id AND {COLUMN} IS NULL"
|
||||
),
|
||||
{"now": now, "user_id": user_id},
|
||||
)
|
||||
else:
|
||||
bind.execute(
|
||||
sa.text(
|
||||
f"INSERT INTO {TABLE} (user_id, {COLUMN}, created_on, changed_on) " # noqa: S608, E501
|
||||
"VALUES (:user_id, :now, :now, :now)"
|
||||
),
|
||||
{"now": now, "user_id": user_id},
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
with op.batch_alter_table(TABLE) as batch_op:
|
||||
batch_op.drop_constraint(UQ, type_="unique")
|
||||
drop_index(TABLE, INDEX)
|
||||
drop_columns(TABLE, COLUMN)
|
||||
@@ -40,6 +40,10 @@ class EmbeddedDashboard(Model, AuditMixinNullable):
|
||||
|
||||
uuid = Column(UUIDType(binary=True), default=uuid.uuid4, primary_key=True)
|
||||
allow_domain_list = Column(Text) # reference the `allowed_domains` property instead
|
||||
# Epoch seconds; guest tokens whose `iat` predates this are rejected. Set to
|
||||
# "now" to revoke all currently-issued guest tokens for this embedded
|
||||
# dashboard. NULL = no revocation.
|
||||
guest_token_revoked_before = Column(Integer, nullable=True)
|
||||
dashboard_id = Column(
|
||||
Integer,
|
||||
ForeignKey("dashboards.id", ondelete="CASCADE"),
|
||||
|
||||
@@ -2289,7 +2289,7 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
|
||||
time_grain: str,
|
||||
time_offset: str | None = None,
|
||||
) -> str:
|
||||
value = row[column_index]
|
||||
value = row.iloc[column_index]
|
||||
|
||||
if hasattr(value, "strftime"):
|
||||
if time_offset and not ExploreMixin.is_valid_date_range_static(time_offset):
|
||||
|
||||
@@ -16,7 +16,15 @@
|
||||
# under the License.
|
||||
|
||||
from flask_appbuilder import Model
|
||||
from sqlalchemy import Column, ForeignKey, Integer, String
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
Column,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from superset import security_manager
|
||||
@@ -34,6 +42,9 @@ class UserAttribute(Model, AuditMixinNullable):
|
||||
"""
|
||||
|
||||
__tablename__ = "user_attribute"
|
||||
# One attribute row per user; readers rely on ``extra_attributes[0]`` and the
|
||||
# session-invalidation upsert depends on this for race safety.
|
||||
__table_args__ = (UniqueConstraint("user_id", name="uq_user_attribute_user_id"),)
|
||||
id = Column(Integer, primary_key=True)
|
||||
user_id = Column(Integer, ForeignKey("ab_user.id"))
|
||||
user = relationship(
|
||||
@@ -42,3 +53,12 @@ class UserAttribute(Model, AuditMixinNullable):
|
||||
welcome_dashboard_id = Column(Integer, ForeignKey("dashboards.id"))
|
||||
welcome_dashboard = relationship("Dashboard")
|
||||
avatar_url = Column(String(100))
|
||||
# When set, any session for this user whose login predates this timestamp
|
||||
# is forced to log out (see ``superset.security.session_invalidation``).
|
||||
# Stamped when the account is disabled, so outstanding sessions terminate
|
||||
# regardless of the session backend. NULL means "never invalidated".
|
||||
sessions_invalidated_at = Column(DateTime, nullable=True, index=True)
|
||||
# When True, the user must change their password before they can use the
|
||||
# rest of the app (enforced by the before-request hook in
|
||||
# superset.security.password_change when ENABLE_FORCE_PASSWORD_CHANGE is on).
|
||||
password_must_change = Column(Boolean, nullable=False, default=False)
|
||||
|
||||
@@ -21,8 +21,9 @@ import logging
|
||||
import re
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from math import ceil
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Callable, cast, NamedTuple, Optional, TYPE_CHECKING
|
||||
from typing import Any, Callable, cast, NamedTuple, Optional, TYPE_CHECKING, Union
|
||||
|
||||
from flask import current_app, Flask, g, Request
|
||||
from flask_appbuilder import Model
|
||||
@@ -86,6 +87,7 @@ from superset.utils.core import (
|
||||
get_username,
|
||||
RowLevelSecurityFilterType,
|
||||
)
|
||||
from superset.utils.decorators import transaction
|
||||
from superset.utils.filters import get_dataset_access_filters
|
||||
from superset.utils.urls import get_url_host
|
||||
|
||||
@@ -638,7 +640,56 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
|
||||
lm.request_loader(self.request_loader)
|
||||
return lm
|
||||
|
||||
def reset_password(self, userid: Union[int, str], password: str) -> None:
|
||||
"""Reset a user's password, clearing the forced-change flag only on a
|
||||
self-service reset.
|
||||
|
||||
Both the self-service reset (``ResetMyPasswordView``) and the admin
|
||||
"Reset Password" action (``ResetPasswordView``) route through this
|
||||
method. The forced-password-change flag must only be cleared when the
|
||||
user resets *their own* password — an admin-initiated reset sets a
|
||||
temporary password and must preserve the "must change at next login"
|
||||
requirement, otherwise the first-use lifecycle would be silently
|
||||
bypassed. We distinguish the two by comparing the acting user
|
||||
(``g.user``) against the target ``userid``: they match for a
|
||||
self-service reset and differ for an admin reset.
|
||||
"""
|
||||
super().reset_password(userid, password)
|
||||
|
||||
acting_user = getattr(g, "user", None)
|
||||
acting_user_id = getattr(acting_user, "id", None)
|
||||
# ``userid`` arrives as a string (the ``pk`` request arg) on the admin
|
||||
# path, so coerce both sides before comparing.
|
||||
is_self_service = acting_user_id is not None and self._same_user(
|
||||
acting_user_id, userid
|
||||
)
|
||||
if is_self_service:
|
||||
from superset.security.password_change import (
|
||||
clear_password_must_change,
|
||||
)
|
||||
|
||||
clear_password_must_change(int(userid))
|
||||
|
||||
@staticmethod
|
||||
def _same_user(left: Any, right: Any) -> bool:
|
||||
"""Return True if two user identifiers refer to the same user.
|
||||
|
||||
Identifiers may be ints or numeric strings (FAB passes the admin-reset
|
||||
target as a ``pk`` request arg string), so compare them as integers and
|
||||
fall back to a string comparison if coercion fails.
|
||||
"""
|
||||
try:
|
||||
return int(left) == int(right)
|
||||
except (TypeError, ValueError):
|
||||
return str(left) == str(right)
|
||||
|
||||
def on_user_login(self, user: Any) -> None:
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from superset.security.session_invalidation import stamp_login_time
|
||||
|
||||
# Record the authentication time so outstanding sessions can be
|
||||
# invalidated when the account is later disabled.
|
||||
stamp_login_time()
|
||||
_log_audit_event(
|
||||
"UserLoggedIn",
|
||||
{"username": user.username, "user_id": user.id},
|
||||
@@ -3702,18 +3753,31 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
|
||||
|
||||
return self.get_guest_user_from_token(cast(GuestToken, token))
|
||||
|
||||
@staticmethod
|
||||
def _is_guest_token_revoked(token: dict[str, Any]) -> bool:
|
||||
@classmethod
|
||||
def _is_guest_token_revoked(cls, token: dict[str, Any]) -> bool:
|
||||
"""
|
||||
Determine whether a guest token has been revoked via a version bump.
|
||||
Determine whether a guest token has been revoked by any mechanism.
|
||||
|
||||
Revocation is opt-in (``GUEST_TOKEN_REVOCATION_ENABLED``). When disabled,
|
||||
no token is ever considered revoked. When enabled, a token is revoked if
|
||||
the version it was minted with is below the expected version. Tokens
|
||||
minted before this feature existed carry no version claim and are treated
|
||||
as :data:`DEFAULT_GUEST_TOKEN_REVOCATION_VERSION` (0), so they only become
|
||||
revoked once an admin has explicitly bumped the expected version above 0.
|
||||
Two complementary revocation mechanisms apply:
|
||||
|
||||
- **Global version bump** (opt-in via ``GUEST_TOKEN_REVOCATION_ENABLED``):
|
||||
a token is revoked if the version it was minted with is below the
|
||||
expected version. Tokens minted before this feature existed carry no
|
||||
version claim and are treated as
|
||||
:data:`DEFAULT_GUEST_TOKEN_REVOCATION_VERSION` (0), so they only become
|
||||
revoked once an admin has explicitly bumped the expected version above 0.
|
||||
- **Per-embedded-dashboard cutoff** (``guest_token_revoked_before``): a
|
||||
token is revoked if its ``iat`` predates the revocation cutoff of any of
|
||||
its embedded-dashboard resources.
|
||||
"""
|
||||
return cls._is_guest_token_revoked_by_version(
|
||||
token
|
||||
) or cls._is_guest_token_revoked_by_embedded(token)
|
||||
|
||||
@staticmethod
|
||||
def _is_guest_token_revoked_by_version(token: dict[str, Any]) -> bool:
|
||||
"""Return True if the token's revocation version is below the expected
|
||||
version. Gated on ``GUEST_TOKEN_REVOCATION_ENABLED``."""
|
||||
if not get_conf()["GUEST_TOKEN_REVOCATION_ENABLED"]:
|
||||
return False
|
||||
token_version = token.get(
|
||||
@@ -3725,6 +3789,67 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
|
||||
token_version = DEFAULT_GUEST_TOKEN_REVOCATION_VERSION
|
||||
return token_version < get_current_guest_token_revocation_version()
|
||||
|
||||
@staticmethod
|
||||
def _is_guest_token_revoked_by_embedded(token: dict[str, Any]) -> bool:
|
||||
"""Return True if the token predates a revocation on any of its
|
||||
embedded-dashboard resources (``guest_token_revoked_before``).
|
||||
|
||||
A token missing ``iat`` cannot prove it was issued after a revocation
|
||||
cutoff, so it is treated as revoked whenever any of its dashboard
|
||||
resources has an active cutoff; otherwise it is not revoked.
|
||||
"""
|
||||
issued_at = token.get("iat")
|
||||
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from superset.daos.dashboard import EmbeddedDashboardDAO
|
||||
from superset.models.dashboard import Dashboard
|
||||
|
||||
for resource in token.get("resources") or []:
|
||||
if resource.get("type") != GuestTokenResourceType.DASHBOARD.value:
|
||||
continue
|
||||
resource_id = str(resource.get("id"))
|
||||
# A dashboard resource id may be an embedded UUID or, during the
|
||||
# UUID migration, a legacy dashboard id. Resolve the embedded
|
||||
# config(s) for either form (mirrors validate_guest_token_resources).
|
||||
embedded = EmbeddedDashboardDAO.find_by_id(resource_id)
|
||||
if embedded:
|
||||
embedded_configs = [embedded]
|
||||
else:
|
||||
dashboard = Dashboard.get(resource_id)
|
||||
embedded_configs = dashboard.embedded if dashboard else []
|
||||
for embedded_config in embedded_configs:
|
||||
revoked_before = getattr(
|
||||
embedded_config, "guest_token_revoked_before", None
|
||||
)
|
||||
if revoked_before is None:
|
||||
continue
|
||||
# Without an issued-at claim the token cannot be shown to
|
||||
# postdate the cutoff, so fail closed and treat it as revoked.
|
||||
if not issued_at or issued_at < revoked_before:
|
||||
return True
|
||||
return False
|
||||
|
||||
@transaction()
|
||||
def revoke_guest_token_access(
|
||||
self, embedded_uuid: str, before: Optional[int] = None
|
||||
) -> None:
|
||||
"""Revoke all guest tokens issued for an embedded dashboard before
|
||||
``before`` (epoch seconds, default: now). Subsequent tokens are
|
||||
unaffected."""
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from superset.daos.dashboard import EmbeddedDashboardDAO
|
||||
|
||||
embedded = EmbeddedDashboardDAO.find_by_id(str(embedded_uuid))
|
||||
if embedded is None:
|
||||
return
|
||||
# Round the cutoff up to the next whole second so that tokens whose
|
||||
# fractional ``iat`` falls within the current second are reliably
|
||||
# revoked (the column stores integer seconds). Rounding up fails
|
||||
# closed: at worst it revokes a token issued slightly after the call.
|
||||
embedded.guest_token_revoked_before = (
|
||||
before if before is not None else ceil(self._get_current_epoch_time())
|
||||
)
|
||||
|
||||
def get_guest_user_from_token(self, token: GuestToken) -> GuestUser:
|
||||
return self.guest_user_cls(
|
||||
token=token,
|
||||
|
||||
211
superset/security/password_change.py
Normal file
211
superset/security/password_change.py
Normal file
@@ -0,0 +1,211 @@
|
||||
# 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.
|
||||
"""Force-password-change-on-first-use enforcement.
|
||||
|
||||
A per-user ``password_must_change`` flag (on ``UserAttribute``) marks accounts —
|
||||
typically created by an administrator — that must set a new password before
|
||||
they can use the rest of the application. When ``ENABLE_FORCE_PASSWORD_CHANGE``
|
||||
is enabled, a ``before_request`` hook redirects such users to the password-reset
|
||||
page until they change it; the flag is cleared automatically on a successful
|
||||
*self-service* password reset (see ``SupersetSecurityManager.reset_password``).
|
||||
An admin-initiated reset deliberately preserves the flag so the target user is
|
||||
still forced to change the temporary password at next login.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from flask import current_app, flash, g, redirect, request, url_for
|
||||
from flask_babel import gettext as __
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from superset.utils.decorators import transaction
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Flask endpoints take the form ``<ViewClass>.<method>`` (or a bare name for
|
||||
# function views). The following must remain reachable while a password change
|
||||
# is pending, otherwise the redirect would loop: the auth views (login/logout
|
||||
# for every auth backend), the password-reset and user-info-edit views, static
|
||||
# assets, and the health check. We match the *view-class* component (the part
|
||||
# before the dot) exactly against the allow-list below rather than doing a
|
||||
# substring search, so unrelated endpoints that merely share a substring (e.g.
|
||||
# an "Author"-named view, or any name containing "health"/"static") are not
|
||||
# accidentally exempted from enforcement.
|
||||
_EXEMPT_VIEW_CLASSES = frozenset(
|
||||
{
|
||||
"AuthDBView",
|
||||
"AuthLDAPView",
|
||||
"AuthOAuthView",
|
||||
"AuthOIDView",
|
||||
"AuthRemoteUserView",
|
||||
"ResetMyPasswordView",
|
||||
"ResetPasswordView",
|
||||
"UserInfoEditView",
|
||||
}
|
||||
)
|
||||
|
||||
# Exact endpoint names (function views / Flask built-ins) that are always exempt.
|
||||
_EXEMPT_ENDPOINTS = frozenset({"static", "appbuilder.static", "health", "healthcheck"})
|
||||
|
||||
|
||||
def _get_user_attribute(user_id: int) -> Optional[Any]:
|
||||
# Imported lazily to avoid import cycles at app-init time.
|
||||
from superset.extensions import db
|
||||
from superset.models.user_attributes import UserAttribute
|
||||
|
||||
# ``user_attribute.user_id`` carries a unique constraint, but databases
|
||||
# migrated from before the constraint existed could contain duplicate rows.
|
||||
# ``.one_or_none()`` would raise ``MultipleResultsFound`` (a 500) in that
|
||||
# case; fetch deterministically by ordering on the primary key and taking
|
||||
# the first row instead.
|
||||
return (
|
||||
db.session.query(UserAttribute)
|
||||
.filter(UserAttribute.user_id == user_id)
|
||||
.order_by(UserAttribute.id)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def password_change_required(user: Any) -> bool:
|
||||
"""Return True if ``user`` has a pending forced password change."""
|
||||
user_id = getattr(user, "id", None)
|
||||
if not user_id:
|
||||
return False
|
||||
attr = _get_user_attribute(user_id)
|
||||
return bool(attr and attr.password_must_change)
|
||||
|
||||
|
||||
@transaction()
|
||||
def set_password_must_change(user_id: int, value: bool = True) -> None:
|
||||
"""Set (or clear) the forced-password-change flag for a user.
|
||||
|
||||
Intended to be called by administrative flows when provisioning an account
|
||||
that should require a password change on first use.
|
||||
"""
|
||||
from superset.extensions import db
|
||||
from superset.models.user_attributes import UserAttribute
|
||||
|
||||
attr = _get_user_attribute(user_id)
|
||||
if attr is None:
|
||||
# ``user_attribute.user_id`` carries a unique constraint, so a
|
||||
# concurrent call for the same user can win the insert between our
|
||||
# read and flush. Insert in a nested transaction and, on conflict,
|
||||
# fall through to update the row the winner created (mirroring the
|
||||
# upsert in ``superset.security.session_invalidation``).
|
||||
try:
|
||||
with db.session.begin_nested():
|
||||
db.session.add(
|
||||
UserAttribute(user_id=user_id, password_must_change=value)
|
||||
)
|
||||
return
|
||||
except IntegrityError:
|
||||
attr = _get_user_attribute(user_id)
|
||||
if attr is None: # pragma: no cover - the conflicting row vanished
|
||||
raise
|
||||
attr.password_must_change = value
|
||||
|
||||
|
||||
@transaction()
|
||||
def clear_password_must_change(user_id: int) -> None:
|
||||
"""Clear the forced-password-change flag for a user, if set."""
|
||||
attr = _get_user_attribute(user_id)
|
||||
if attr and attr.password_must_change:
|
||||
attr.password_must_change = False
|
||||
|
||||
|
||||
def _is_exempt_endpoint(endpoint: Optional[str]) -> bool:
|
||||
# A missing endpoint (e.g. an unmatched URL) is left to normal 404 handling.
|
||||
if not endpoint:
|
||||
return True
|
||||
if endpoint in _EXEMPT_ENDPOINTS:
|
||||
return True
|
||||
# Any blueprint's static route, e.g. "<blueprint>.static".
|
||||
if endpoint.endswith(".static"):
|
||||
return True
|
||||
# Match the view-class component exactly, so e.g. "AuthDBView.login" is
|
||||
# exempt but an unrelated "AuthorView.list" is not.
|
||||
view_class = endpoint.split(".", 1)[0]
|
||||
return view_class in _EXEMPT_VIEW_CLASSES
|
||||
|
||||
|
||||
def register_password_change_enforcement(app: Any) -> None:
|
||||
"""Register the before-request hook that enforces pending password changes.
|
||||
|
||||
No-op unless ``ENABLE_FORCE_PASSWORD_CHANGE`` is enabled, so there is zero
|
||||
per-request overhead in the default configuration.
|
||||
"""
|
||||
|
||||
@app.before_request
|
||||
def _enforce_password_change() -> Any: # pylint: disable=unused-variable
|
||||
"""Redirect flagged users to the password-reset page.
|
||||
|
||||
Returns ``None`` (request proceeds) for anonymous users, exempt
|
||||
endpoints, and users without a pending change; otherwise returns a
|
||||
redirect to an exempt target (or an error response if none resolves).
|
||||
"""
|
||||
if not current_app.config.get("ENABLE_FORCE_PASSWORD_CHANGE"):
|
||||
return None
|
||||
|
||||
user = getattr(g, "user", None)
|
||||
if not user or getattr(user, "is_anonymous", True):
|
||||
return None
|
||||
|
||||
if _is_exempt_endpoint(request.endpoint):
|
||||
return None
|
||||
|
||||
if not password_change_required(user):
|
||||
return None
|
||||
|
||||
flash(__("You must change your password before continuing."), "warning")
|
||||
# Resolve the password-reset page. If that endpoint can't be resolved
|
||||
# (e.g. a custom security manager without ``ResetMyPasswordView``), fall
|
||||
# back to logout, which is always exempt from this enforcement. The
|
||||
# logout endpoint is derived from the *registered* auth view so the
|
||||
# fallback works for non-DB auth backends (LDAP, OAuth, remote-user)
|
||||
# too, with ``AuthDBView.logout`` as a last resort. We must NOT fall
|
||||
# back to "/" or any other non-exempt route: the index re-runs this
|
||||
# same hook and would trap the user in an infinite 302 loop. If no
|
||||
# exempt target can be resolved at all, return an error response rather
|
||||
# than redirect, so a flagged user can never get stuck looping.
|
||||
candidates = ["ResetMyPasswordView.this_form_get"]
|
||||
auth_view = getattr(
|
||||
getattr(getattr(current_app, "appbuilder", None), "sm", None),
|
||||
"auth_view",
|
||||
None,
|
||||
)
|
||||
# Only redirect to the registered auth view's logout if that view is
|
||||
# itself exempt from this hook; otherwise the redirect would loop.
|
||||
if (
|
||||
auth_view is not None
|
||||
and getattr(auth_view, "endpoint", None) in _EXEMPT_VIEW_CLASSES
|
||||
):
|
||||
candidates.append(f"{auth_view.endpoint}.logout")
|
||||
candidates.append("AuthDBView.logout")
|
||||
for endpoint in candidates:
|
||||
try:
|
||||
return redirect(url_for(endpoint))
|
||||
except Exception: # noqa: BLE001, S112 # pylint: disable=broad-except
|
||||
# Try the next exempt fallback; a failed url_for resolution here
|
||||
# is expected/benign and not worth logging per attempt.
|
||||
continue
|
||||
return (
|
||||
__("You must change your password before continuing."),
|
||||
503,
|
||||
)
|
||||
205
superset/security/session_invalidation.py
Normal file
205
superset/security/session_invalidation.py
Normal file
@@ -0,0 +1,205 @@
|
||||
# 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.
|
||||
"""
|
||||
Backend-agnostic session invalidation.
|
||||
|
||||
Outstanding sessions are terminated by comparing the time a session was
|
||||
authenticated (``session["_login_at"]``, stamped at login) against a per-user
|
||||
invalidation epoch (``UserAttribute.sessions_invalidated_at``). When a session
|
||||
predates the user's epoch it is forced to log out on its next request.
|
||||
|
||||
The epoch is stamped whenever an account is *disabled* (``active`` flips to
|
||||
``False``), via a SQLAlchemy ``after_update`` listener so it fires regardless of
|
||||
the code path that disabled the user (FAB admin UI, REST API, or CLI). This
|
||||
works for both client-side cookie sessions and server-side session stores
|
||||
without enumerating the store by user. A deleted user is already rejected by
|
||||
Flask-Login's user loader, so deletion needs no epoch.
|
||||
|
||||
The mechanism is inert until an epoch is set: users that were never disabled
|
||||
(NULL epoch) are never affected, so it is backwards compatible by default.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import math
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
from flask import flash, session
|
||||
from flask_babel import gettext as __
|
||||
from flask_login import current_user, logout_user
|
||||
from sqlalchemy import event, inspect
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from werkzeug.wrappers import Response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Session key holding the epoch-seconds timestamp of when the session logged in.
|
||||
SESSION_LOGIN_AT_KEY = "_login_at"
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def stamp_login_time() -> None:
|
||||
"""Record the current session's authentication time. Call on login."""
|
||||
session[SESSION_LOGIN_AT_KEY] = _utcnow().timestamp()
|
||||
|
||||
|
||||
def _as_utc_timestamp(value: datetime) -> float:
|
||||
"""Epoch seconds for ``value``, treating naive datetimes as UTC.
|
||||
|
||||
The ``sessions_invalidated_at`` column is a naive ``DateTime`` storing a UTC
|
||||
instant; calling ``.timestamp()`` on a naive datetime would otherwise assume
|
||||
*local* time and skew the comparison by the local UTC offset.
|
||||
"""
|
||||
if value.tzinfo is None:
|
||||
value = value.replace(tzinfo=timezone.utc)
|
||||
return value.timestamp()
|
||||
|
||||
|
||||
def is_session_invalidated(
|
||||
login_at: Optional[float], invalidated_at: Optional[datetime]
|
||||
) -> bool:
|
||||
"""
|
||||
Return True if a session authenticated at ``login_at`` is invalidated by an
|
||||
epoch of ``invalidated_at``.
|
||||
|
||||
- No epoch (``invalidated_at is None``) ⇒ never invalidated (the common case
|
||||
and the reason the feature is inert/backwards compatible by default).
|
||||
- An epoch with no recorded login time ⇒ invalidated. A session old enough
|
||||
to predate the feature carries no ``_login_at``; if the user has since
|
||||
been disabled, fail closed.
|
||||
- Otherwise the session is invalidated iff it logged in before the epoch.
|
||||
"""
|
||||
if invalidated_at is None:
|
||||
return False
|
||||
if login_at is None:
|
||||
return True
|
||||
return login_at < _as_utc_timestamp(invalidated_at)
|
||||
|
||||
|
||||
def _get_user_invalidated_at(user: Any) -> Optional[datetime]:
|
||||
extra_attributes = getattr(user, "extra_attributes", None)
|
||||
if not extra_attributes:
|
||||
return None
|
||||
return extra_attributes[0].sessions_invalidated_at
|
||||
|
||||
|
||||
def enforce_session_validity() -> Optional[Response]:
|
||||
"""
|
||||
``before_request`` hook: force logout of sessions invalidated by the user's
|
||||
epoch.
|
||||
|
||||
Fails open — any error here logs a warning and allows the request rather
|
||||
than risk locking everyone out on a bug in the check.
|
||||
"""
|
||||
try:
|
||||
user = current_user
|
||||
if not user or not getattr(user, "is_authenticated", False):
|
||||
return None
|
||||
# Guest (embedded) users are not FAB users and have their own
|
||||
# revocation mechanism; skip them.
|
||||
if getattr(user, "is_guest_user", False):
|
||||
return None
|
||||
invalidated_at = _get_user_invalidated_at(user)
|
||||
if invalidated_at is None:
|
||||
return None
|
||||
login_at = session.get(SESSION_LOGIN_AT_KEY)
|
||||
if not is_session_invalidated(login_at, invalidated_at):
|
||||
return None
|
||||
|
||||
# Clear the authenticated session and let the request continue as
|
||||
# anonymous: each route's own decorator then responds correctly for its
|
||||
# type (401 for the REST API, redirect-to-login for HTML views) without
|
||||
# this hook needing to know the route kind.
|
||||
logout_user()
|
||||
session.clear()
|
||||
flash(__("Your session has ended. Please sign in again."), "warning")
|
||||
return None
|
||||
except Exception: # noqa: BLE001 # pylint: disable=broad-except
|
||||
logger.warning(
|
||||
"Session-invalidation check failed; allowing request", exc_info=True
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def invalidate_user_sessions(connection: Any, user_id: int) -> None:
|
||||
"""Stamp the invalidation epoch for ``user_id`` using ``connection``.
|
||||
|
||||
Upserts the user's ``UserAttribute`` row so the mechanism works even for
|
||||
users that have no attribute row yet. ``user_attribute.user_id`` carries a
|
||||
unique constraint, so the insert is safe against a concurrent disable of the
|
||||
same user: the loser's insert raises ``IntegrityError``, which is caught and
|
||||
retried as an update rather than creating a duplicate row.
|
||||
"""
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from superset.models.user_attributes import UserAttribute
|
||||
|
||||
table = UserAttribute.__table__
|
||||
# Round the epoch up to the next whole second. Some backends (e.g. MySQL)
|
||||
# store ``DATETIME`` columns without sub-second precision and truncate the
|
||||
# value; a session that logged in earlier in the same wall-clock second
|
||||
# carries a fractional ``_login_at`` that would otherwise compare as >= the
|
||||
# truncated epoch and survive invalidation. Ceiling the stamp guarantees it
|
||||
# strictly exceeds any login time from the same second.
|
||||
now_epoch = _utcnow().timestamp()
|
||||
now = datetime.fromtimestamp(math.ceil(now_epoch), timezone.utc).replace(
|
||||
tzinfo=None
|
||||
)
|
||||
|
||||
def _stamp_existing() -> int:
|
||||
return connection.execute(
|
||||
table.update()
|
||||
.where(table.c.user_id == user_id)
|
||||
.values(sessions_invalidated_at=now, changed_on=now)
|
||||
).rowcount
|
||||
|
||||
if _stamp_existing():
|
||||
return
|
||||
|
||||
try:
|
||||
with connection.begin_nested():
|
||||
connection.execute(
|
||||
table.insert().values(
|
||||
user_id=user_id,
|
||||
sessions_invalidated_at=now,
|
||||
created_on=now,
|
||||
changed_on=now,
|
||||
)
|
||||
)
|
||||
except IntegrityError:
|
||||
# A concurrent disable inserted the row first; stamp it instead.
|
||||
_stamp_existing()
|
||||
|
||||
|
||||
def _stamp_epoch_on_disable(_mapper: Any, connection: Any, target: Any) -> None:
|
||||
history = inspect(target).attrs.active.history
|
||||
# Only act when ``active`` actually changed to False — ignore the
|
||||
# last_login / login_count updates FAB writes on every login.
|
||||
if not history.has_changes() or target.active:
|
||||
return
|
||||
invalidate_user_sessions(connection, target.id)
|
||||
|
||||
|
||||
def register_session_invalidation_events(user_model: Any) -> None:
|
||||
"""Register the ``after_update`` listener that stamps the epoch on disable.
|
||||
|
||||
Idempotent: safe to call on every app initialization (e.g. across tests).
|
||||
"""
|
||||
if not event.contains(user_model, "after_update", _stamp_epoch_on_disable):
|
||||
event.listen(user_model, "after_update", _stamp_epoch_on_disable)
|
||||
@@ -25,6 +25,7 @@ from flask import g # noqa: F401
|
||||
from superset import db, security_manager
|
||||
from superset.commands.chart.create import CreateChartCommand
|
||||
from superset.commands.chart.exceptions import (
|
||||
ChartForbiddenError,
|
||||
ChartNotFoundError,
|
||||
WarmUpCacheChartNotFoundError,
|
||||
)
|
||||
@@ -452,6 +453,43 @@ class TestChartsUpdateCommand(SupersetTestCase):
|
||||
assert len(chart.owners) == 1
|
||||
assert chart.owners[0] == admin
|
||||
|
||||
@patch("superset.commands.chart.update.ChartDAO.find_by_id")
|
||||
@patch("superset.commands.chart.update.g")
|
||||
@patch("superset.utils.core.g")
|
||||
@patch("superset.security.manager.g")
|
||||
@pytest.mark.usefixtures("load_energy_table_with_slice")
|
||||
def test_query_context_update_requires_chart_access(
|
||||
self, mock_sm_g, mock_core_g, mock_update_g, mock_find_by_id
|
||||
) -> None:
|
||||
"""
|
||||
A query_context-only update relaxes the ownership requirement but must
|
||||
still require access to the chart. We bypass the DAO ``ChartFilter``
|
||||
base filter (by patching ``find_by_id`` to return the chart directly)
|
||||
so the request reaches the new explicit ``raise_for_access`` check, and
|
||||
assert that a non-owner with no access to the chart's datasource is
|
||||
rejected with ``ChartForbiddenError``. This deterministically exercises
|
||||
the new branch and would fail on master, where the check is absent.
|
||||
"""
|
||||
chart = db.session.query(Slice).filter_by(slice_name="Energy Sankey").one()
|
||||
pk = chart.id
|
||||
admin = security_manager.find_user(username="admin")
|
||||
chart.owners = [admin]
|
||||
db.session.commit()
|
||||
|
||||
# Return the chart directly, bypassing ChartFilter, so the command's
|
||||
# own raise_for_access gate is what denies the request.
|
||||
mock_find_by_id.return_value = chart
|
||||
|
||||
# gamma has no access to the energy datasource and does not own the chart
|
||||
gamma = security_manager.find_user(username="gamma")
|
||||
mock_core_g.user = mock_sm_g.user = mock_update_g.user = gamma
|
||||
json_obj = {
|
||||
"query_context_generation": True,
|
||||
"query_context": json.dumps({"foo": "bar"}),
|
||||
}
|
||||
with pytest.raises(ChartForbiddenError):
|
||||
UpdateChartCommand(pk, json_obj).run()
|
||||
|
||||
@patch("superset.commands.chart.update.g")
|
||||
@patch("superset.utils.core.g")
|
||||
@patch("superset.security.manager.g")
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
# 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 superset import db
|
||||
from superset.models.user_attributes import UserAttribute
|
||||
from tests.integration_tests.base_tests import SupersetTestCase
|
||||
|
||||
USERNAME = "session_invalidation_user"
|
||||
PASSWORD = "general" # noqa: S105
|
||||
|
||||
|
||||
class TestSessionInvalidation(SupersetTestCase):
|
||||
def setUp(self) -> None:
|
||||
self.create_user(
|
||||
USERNAME,
|
||||
PASSWORD,
|
||||
"Admin",
|
||||
email="session_invalidation_user@fab.org",
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
if user := self.get_user(USERNAME):
|
||||
db.session.query(UserAttribute).filter_by(user_id=user.id).delete()
|
||||
db.session.delete(user)
|
||||
db.session.commit()
|
||||
|
||||
def _set_active(self, active: bool) -> None:
|
||||
# Update via the ORM so the after_update event fires (mirrors the
|
||||
# FAB admin UI / REST API path that flips ``active``).
|
||||
user = self.get_user(USERNAME)
|
||||
user.active = active
|
||||
db.session.commit()
|
||||
|
||||
def test_disabling_user_forces_logout_of_active_session(self) -> None:
|
||||
self.login(USERNAME, PASSWORD)
|
||||
|
||||
# Authenticated request works before the account is disabled.
|
||||
assert self.client.get("/api/v1/me/").status_code == 200
|
||||
|
||||
# Disabling the account stamps the per-user invalidation epoch...
|
||||
self._set_active(False)
|
||||
user = self.get_user(USERNAME)
|
||||
attribute = (
|
||||
db.session.query(UserAttribute).filter_by(user_id=user.id).one_or_none()
|
||||
)
|
||||
assert attribute is not None
|
||||
assert attribute.sessions_invalidated_at is not None
|
||||
|
||||
# ...so the previously-authenticated session is now forced out. The
|
||||
# hook clears the session and the protected REST route answers 401.
|
||||
assert self.client.get("/api/v1/me/").status_code == 401
|
||||
|
||||
def test_active_user_session_is_unaffected(self) -> None:
|
||||
"""A user who was never disabled (NULL epoch) is never logged out."""
|
||||
self.login(USERNAME, PASSWORD)
|
||||
assert self.client.get("/api/v1/me/").status_code == 200
|
||||
# No epoch was ever stamped.
|
||||
user = self.get_user(USERNAME)
|
||||
attribute = (
|
||||
db.session.query(UserAttribute).filter_by(user_id=user.id).one_or_none()
|
||||
)
|
||||
assert attribute is None or attribute.sessions_invalidated_at is None
|
||||
# Repeated requests stay authenticated.
|
||||
assert self.client.get("/api/v1/me/").status_code == 200
|
||||
@@ -17,10 +17,11 @@
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from superset.commands.chart.exceptions import ChartForbiddenError
|
||||
from superset.commands.chart.exceptions import ChartForbiddenError, ChartInvalidError
|
||||
from superset.commands.chart.update import UpdateChartCommand
|
||||
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
from superset.utils import json
|
||||
|
||||
|
||||
def _ownership_exc() -> SupersetSecurityException:
|
||||
@@ -33,6 +34,16 @@ def _ownership_exc() -> SupersetSecurityException:
|
||||
)
|
||||
|
||||
|
||||
def _access_exc() -> SupersetSecurityException:
|
||||
return SupersetSecurityException(
|
||||
SupersetError(
|
||||
error_type=SupersetErrorType.CHART_SECURITY_ACCESS_ERROR,
|
||||
message="User does not have access to this chart",
|
||||
level=ErrorLevel.ERROR,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_update_chart_ownership_enforced_for_regular_update(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
@@ -54,20 +65,70 @@ def test_update_chart_ownership_enforced_for_regular_update(
|
||||
def test_update_chart_query_context_skips_ownership_check(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Query-context-only updates skip ownership so report workers can save context."""
|
||||
"""Query-context-only updates skip the ownership check (so report workers can
|
||||
save context) but still require access to the chart."""
|
||||
find_by_id = mocker.patch("superset.commands.chart.update.ChartDAO.find_by_id")
|
||||
find_by_id.return_value = mocker.MagicMock(id=1, tags=[], dashboards=[])
|
||||
raise_for_ownership = mocker.patch(
|
||||
"superset.commands.chart.update.security_manager.raise_for_ownership",
|
||||
side_effect=_ownership_exc(),
|
||||
)
|
||||
raise_for_access = mocker.patch(
|
||||
"superset.commands.chart.update.security_manager.raise_for_access",
|
||||
)
|
||||
|
||||
UpdateChartCommand(
|
||||
1, {"query_context": "{}", "query_context_generation": True}
|
||||
).validate()
|
||||
|
||||
find_by_id.assert_called_once_with(1)
|
||||
# ownership is relaxed, but chart access is still enforced
|
||||
raise_for_ownership.assert_not_called()
|
||||
raise_for_access.assert_called_once_with(chart=find_by_id.return_value)
|
||||
|
||||
|
||||
def test_update_chart_query_context_requires_chart_access(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""A query-context-only update by someone without access to the chart is
|
||||
rejected, even though the ownership check is relaxed for this path."""
|
||||
find_by_id = mocker.patch("superset.commands.chart.update.ChartDAO.find_by_id")
|
||||
find_by_id.return_value = mocker.MagicMock(id=1, tags=[], dashboards=[])
|
||||
mocker.patch(
|
||||
"superset.commands.chart.update.security_manager.raise_for_access",
|
||||
side_effect=_access_exc(),
|
||||
)
|
||||
|
||||
with pytest.raises(ChartForbiddenError):
|
||||
UpdateChartCommand(
|
||||
1, {"query_context": "{}", "query_context_generation": True}
|
||||
).validate()
|
||||
|
||||
|
||||
def test_update_chart_query_context_non_owner_with_access_allowed(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""A non-owner who *does* have access to the chart (e.g. an alpha user with
|
||||
datasource access, or a report worker) can perform a query-context-only
|
||||
backfill: ownership is relaxed and ``raise_for_access`` does not deny."""
|
||||
find_by_id = mocker.patch("superset.commands.chart.update.ChartDAO.find_by_id")
|
||||
find_by_id.return_value = mocker.MagicMock(id=1, tags=[], dashboards=[])
|
||||
raise_for_ownership = mocker.patch(
|
||||
"superset.commands.chart.update.security_manager.raise_for_ownership",
|
||||
side_effect=_ownership_exc(),
|
||||
)
|
||||
# access check passes (no exception) -> the non-owner is permitted
|
||||
raise_for_access = mocker.patch(
|
||||
"superset.commands.chart.update.security_manager.raise_for_access",
|
||||
)
|
||||
|
||||
# Should not raise: the update is allowed despite the user not being an owner
|
||||
UpdateChartCommand(
|
||||
1, {"query_context": "{}", "query_context_generation": True}
|
||||
).validate()
|
||||
|
||||
raise_for_ownership.assert_not_called()
|
||||
raise_for_access.assert_called_once_with(chart=find_by_id.return_value)
|
||||
|
||||
|
||||
def test_update_chart_owner_can_perform_regular_update(
|
||||
@@ -91,3 +152,78 @@ def test_update_chart_owner_can_perform_regular_update(
|
||||
|
||||
find_by_id.assert_called_once_with(1)
|
||||
raise_for_ownership.assert_called_once()
|
||||
|
||||
|
||||
def _query_context_payload(datasource: object) -> dict[str, object]:
|
||||
"""Build a query-context-only update payload targeting ``datasource``."""
|
||||
return {
|
||||
"query_context": json.dumps({"datasource": datasource, "queries": []}),
|
||||
"query_context_generation": True,
|
||||
}
|
||||
|
||||
|
||||
def test_update_chart_query_context_matching_datasource_is_allowed(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""A query context that targets the chart's own datasource is accepted."""
|
||||
find_by_id = mocker.patch("superset.commands.chart.update.ChartDAO.find_by_id")
|
||||
find_by_id.return_value = mocker.MagicMock(
|
||||
id=1, tags=[], dashboards=[], datasource_id=42, datasource_type="table"
|
||||
)
|
||||
mocker.patch("superset.commands.chart.update.security_manager.raise_for_ownership")
|
||||
mocker.patch("superset.commands.chart.update.security_manager.raise_for_access")
|
||||
|
||||
UpdateChartCommand(
|
||||
1, _query_context_payload({"id": 42, "type": "table"})
|
||||
).validate()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"datasource",
|
||||
[
|
||||
{"id": 99, "type": "table"}, # different id
|
||||
{"id": 42, "type": "query"}, # different type
|
||||
{"id": "99", "type": "table"}, # different id as string
|
||||
{"id": 42}, # matching id but missing type
|
||||
],
|
||||
)
|
||||
def test_update_chart_query_context_mismatched_datasource_is_rejected(
|
||||
mocker: MockerFixture,
|
||||
datasource: dict[str, object],
|
||||
) -> None:
|
||||
"""A query context pointing at a different datasource is rejected with a 4xx."""
|
||||
find_by_id = mocker.patch("superset.commands.chart.update.ChartDAO.find_by_id")
|
||||
find_by_id.return_value = mocker.MagicMock(
|
||||
id=1, tags=[], dashboards=[], datasource_id=42, datasource_type="table"
|
||||
)
|
||||
mocker.patch("superset.commands.chart.update.security_manager.raise_for_ownership")
|
||||
mocker.patch("superset.commands.chart.update.security_manager.raise_for_access")
|
||||
|
||||
with pytest.raises(ChartInvalidError):
|
||||
UpdateChartCommand(1, _query_context_payload(datasource)).validate()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"query_context",
|
||||
[
|
||||
"{}", # no datasource key
|
||||
'{"datasource": null}', # null datasource
|
||||
"not-json", # unparseable payload
|
||||
],
|
||||
)
|
||||
def test_update_chart_query_context_without_datasource_is_allowed(
|
||||
mocker: MockerFixture,
|
||||
query_context: str,
|
||||
) -> None:
|
||||
"""Payloads with no verifiable datasource fall back to the chart's own."""
|
||||
find_by_id = mocker.patch("superset.commands.chart.update.ChartDAO.find_by_id")
|
||||
find_by_id.return_value = mocker.MagicMock(
|
||||
id=1, tags=[], dashboards=[], datasource_id=42, datasource_type="table"
|
||||
)
|
||||
mocker.patch("superset.commands.chart.update.security_manager.raise_for_ownership")
|
||||
mocker.patch("superset.commands.chart.update.security_manager.raise_for_access")
|
||||
|
||||
UpdateChartCommand(
|
||||
1,
|
||||
{"query_context": query_context, "query_context_generation": True},
|
||||
).validate()
|
||||
|
||||
@@ -20,6 +20,7 @@ Unit tests for update_chart MCP tool
|
||||
"""
|
||||
|
||||
import importlib
|
||||
from typing import Any
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
@@ -582,7 +583,7 @@ class TestBuildUpdatePayload:
|
||||
assert result == {"slice_name": "New Name"}
|
||||
|
||||
def test_error_when_no_config_and_no_name(self):
|
||||
"""Returns GenerateChartResponse error when neither config nor chart_name."""
|
||||
"""Returns error when neither config, chart_name, nor dataset_id is provided."""
|
||||
request = UpdateChartRequest(identifier=1)
|
||||
chart = Mock()
|
||||
|
||||
@@ -594,6 +595,7 @@ class TestBuildUpdatePayload:
|
||||
assert result.error.error_type == "ValidationError"
|
||||
assert "config" in result.error.message.lower()
|
||||
assert "chart_name" in result.error.message.lower()
|
||||
assert "dataset_id" in result.error.message.lower()
|
||||
|
||||
def test_config_update_uses_request_chart_name(self):
|
||||
"""When config and chart_name are both provided, uses chart_name."""
|
||||
@@ -1344,3 +1346,292 @@ class TestUpdateChartSqlMetric:
|
||||
assert "<UNTRUSTED-CONTENT>" in m["sqlExpression"]
|
||||
assert "<UNTRUSTED-CONTENT>" in m["label"]
|
||||
assert "<UNTRUSTED-CONTENT>" not in m["optionName"]
|
||||
|
||||
|
||||
class TestBuildUpdatePayloadDatasetId:
|
||||
"""Tests for dataset_id support in _build_update_payload."""
|
||||
|
||||
def test_dataset_only_update_returns_datasource_fields(self) -> None:
|
||||
"""dataset_id alone produces a payload with datasource_id + datasource_type."""
|
||||
request = UpdateChartRequest(identifier=1, dataset_id=42)
|
||||
chart = Mock()
|
||||
chart.datasource_id = 10
|
||||
|
||||
result = _build_update_payload(request, chart)
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert result == {"datasource_id": 42, "datasource_type": "table"}
|
||||
|
||||
def test_dataset_and_name_update(self) -> None:
|
||||
"""dataset_id + chart_name: payload includes datasource fields
|
||||
and slice_name."""
|
||||
request = UpdateChartRequest(identifier=1, dataset_id=42, chart_name="Renamed")
|
||||
chart = Mock()
|
||||
chart.datasource_id = 10
|
||||
|
||||
result = _build_update_payload(request, chart)
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert result == {
|
||||
"datasource_id": 42,
|
||||
"datasource_type": "table",
|
||||
"slice_name": "Renamed",
|
||||
}
|
||||
|
||||
def test_dataset_and_config_update_includes_datasource(self):
|
||||
"""dataset_id + config: payload includes datasource_id and datasource_type."""
|
||||
config = TableChartConfig(
|
||||
chart_type="table",
|
||||
columns=[ColumnRef(name="col1")],
|
||||
)
|
||||
request = UpdateChartRequest(identifier=1, config=config, dataset_id=99)
|
||||
chart = Mock()
|
||||
chart.datasource_id = 10
|
||||
chart.slice_name = "Old Name"
|
||||
|
||||
result = _build_update_payload(request, chart, parsed_config=config)
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert result["datasource_id"] == 99
|
||||
assert result["datasource_type"] == "table"
|
||||
assert "params" in result
|
||||
assert "viz_type" in result
|
||||
|
||||
def test_config_without_dataset_does_not_include_datasource(self):
|
||||
"""When dataset_id is None, payload must NOT include datasource_id."""
|
||||
config = TableChartConfig(
|
||||
chart_type="table",
|
||||
columns=[ColumnRef(name="col1")],
|
||||
)
|
||||
request = UpdateChartRequest(identifier=1, config=config)
|
||||
chart = Mock()
|
||||
chart.datasource_id = 10
|
||||
chart.slice_name = "Old Name"
|
||||
|
||||
result = _build_update_payload(request, chart, parsed_config=config)
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert "datasource_id" not in result
|
||||
assert "datasource_type" not in result
|
||||
|
||||
|
||||
class TestBuildPreviewFormDataDatasetId:
|
||||
"""Tests for dataset_id support in _build_preview_form_data."""
|
||||
|
||||
def test_dataset_only_update_sets_datasource_field(self):
|
||||
"""dataset_id alone updates the datasource field in merged form_data."""
|
||||
request = UpdateChartRequest(identifier=1, dataset_id=55)
|
||||
chart = Mock()
|
||||
chart.datasource_id = 10
|
||||
chart.slice_name = "Chart"
|
||||
chart.id = 1
|
||||
chart.params = None
|
||||
|
||||
result = _build_preview_form_data(request, chart)
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert result["datasource"] == "55__table"
|
||||
|
||||
def test_config_and_dataset_uses_new_dataset(self):
|
||||
"""config + dataset_id: datasource field reflects the new dataset."""
|
||||
config = TableChartConfig(
|
||||
chart_type="table",
|
||||
columns=[ColumnRef(name="col1")],
|
||||
)
|
||||
request = UpdateChartRequest(identifier=1, config=config, dataset_id=77)
|
||||
chart = Mock()
|
||||
chart.datasource_id = 10
|
||||
chart.slice_name = "Chart"
|
||||
chart.id = 1
|
||||
chart.params = None
|
||||
|
||||
result = _build_preview_form_data(request, chart, parsed_config=config)
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert result["datasource"] == "77__table"
|
||||
|
||||
def test_no_dataset_keeps_existing_datasource(self):
|
||||
"""When dataset_id is None, datasource reflects the existing chart dataset."""
|
||||
config = TableChartConfig(
|
||||
chart_type="table",
|
||||
columns=[ColumnRef(name="col1")],
|
||||
)
|
||||
request = UpdateChartRequest(identifier=1, config=config)
|
||||
chart = Mock()
|
||||
chart.datasource_id = 10
|
||||
chart.slice_name = "Chart"
|
||||
chart.id = 1
|
||||
chart.params = None
|
||||
|
||||
result = _build_preview_form_data(request, chart, parsed_config=config)
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert result["datasource"] == "10__table"
|
||||
|
||||
|
||||
class TestUpdateChartDatasetIdIntegration:
|
||||
"""Integration test verifying dataset_id is plumbed into UpdateChartCommand."""
|
||||
|
||||
@patch(
|
||||
"superset.mcp_service.auth.check_chart_data_access",
|
||||
new_callable=Mock,
|
||||
)
|
||||
@patch(
|
||||
"superset.commands.chart.update.UpdateChartCommand",
|
||||
new_callable=Mock,
|
||||
)
|
||||
@patch(
|
||||
"superset.mcp_service.chart.tool.update_chart._validate_update_against_dataset",
|
||||
return_value=None,
|
||||
)
|
||||
@patch("superset.daos.chart.ChartDAO.find_by_id", new_callable=Mock)
|
||||
@patch("superset.db.session")
|
||||
@pytest.mark.asyncio
|
||||
async def test_dataset_id_passed_to_update_command(
|
||||
self,
|
||||
mock_db_session: Any,
|
||||
mock_find_by_id: Mock,
|
||||
mock_validate: Mock,
|
||||
mock_update_cmd_cls: Mock,
|
||||
mock_check_access: Mock,
|
||||
mcp_server: Any,
|
||||
) -> None:
|
||||
"""dataset_id in request is forwarded to UpdateChartCommand payload."""
|
||||
mock_chart = Mock()
|
||||
mock_chart.id = 55
|
||||
mock_chart.datasource_id = 10
|
||||
mock_chart.slice_name = "Old Chart"
|
||||
mock_chart.viz_type = "table"
|
||||
mock_chart.uuid = "uuid-55"
|
||||
mock_find_by_id.return_value = mock_chart
|
||||
|
||||
mock_check_access.return_value = DatasetValidationResult(
|
||||
is_valid=True,
|
||||
dataset_id=10,
|
||||
dataset_name="old_dataset",
|
||||
warnings=[],
|
||||
)
|
||||
|
||||
updated_chart = Mock()
|
||||
updated_chart.id = 55
|
||||
updated_chart.slice_name = "Old Chart"
|
||||
updated_chart.viz_type = "table"
|
||||
updated_chart.uuid = "uuid-55"
|
||||
mock_update_cmd_cls.return_value.run.return_value = updated_chart
|
||||
|
||||
request = {
|
||||
"identifier": 55,
|
||||
"dataset_id": 1041,
|
||||
"generate_preview": False,
|
||||
}
|
||||
|
||||
async with Client(mcp) as client:
|
||||
result = await client.call_tool("update_chart", {"request": request})
|
||||
|
||||
assert result.structured_content["success"] is True
|
||||
|
||||
call_args = mock_update_cmd_cls.call_args
|
||||
payload = call_args[0][1]
|
||||
assert payload.get("datasource_id") == 1041
|
||||
assert payload.get("datasource_type") == "table"
|
||||
|
||||
@patch(
|
||||
"superset.mcp_service.auth.check_chart_data_access",
|
||||
new_callable=Mock,
|
||||
)
|
||||
@patch("superset.daos.dataset.DatasetDAO.find_by_id", new_callable=Mock)
|
||||
@patch("superset.daos.chart.ChartDAO.find_by_id", new_callable=Mock)
|
||||
@patch("superset.db.session")
|
||||
@pytest.mark.asyncio
|
||||
async def test_dataset_only_rebind_invalid_dataset_returns_error(
|
||||
self,
|
||||
mock_db_session: Any,
|
||||
mock_chart_find: Mock,
|
||||
mock_dataset_find: Mock,
|
||||
mock_check_access: Mock,
|
||||
mcp_server: Any,
|
||||
) -> None:
|
||||
"""dataset_id pointing to a non-existent dataset returns
|
||||
DatasetNotAccessible."""
|
||||
mock_chart = Mock()
|
||||
mock_chart.id = 55
|
||||
mock_chart.datasource_id = 10
|
||||
mock_chart.slice_name = "Old Chart"
|
||||
mock_chart.viz_type = "table"
|
||||
mock_chart.uuid = "uuid-55"
|
||||
mock_chart_find.return_value = mock_chart
|
||||
|
||||
mock_check_access.return_value = DatasetValidationResult(
|
||||
is_valid=True,
|
||||
dataset_id=10,
|
||||
dataset_name="old_dataset",
|
||||
warnings=[],
|
||||
)
|
||||
|
||||
# Target dataset does not exist
|
||||
mock_dataset_find.return_value = None
|
||||
|
||||
request = {
|
||||
"identifier": 55,
|
||||
"dataset_id": 9999,
|
||||
"generate_preview": False,
|
||||
}
|
||||
|
||||
async with Client(mcp) as client:
|
||||
result = await client.call_tool("update_chart", {"request": request})
|
||||
|
||||
assert result.structured_content["success"] is False
|
||||
error_type = result.structured_content["error"]["error_type"]
|
||||
assert error_type == "DatasetNotAccessible"
|
||||
assert "9999" in result.structured_content["error"]["details"]
|
||||
|
||||
@patch(
|
||||
"superset.mcp_service.auth.check_chart_data_access",
|
||||
new_callable=Mock,
|
||||
)
|
||||
@patch("superset.daos.dataset.DatasetDAO.find_by_id", new_callable=Mock)
|
||||
@patch("superset.daos.chart.ChartDAO.find_by_id", new_callable=Mock)
|
||||
@patch("superset.db.session")
|
||||
@pytest.mark.asyncio
|
||||
async def test_dataset_only_rebind_invalid_dataset_preview_returns_error(
|
||||
self,
|
||||
mock_db_session: Any,
|
||||
mock_chart_find: Mock,
|
||||
mock_dataset_find: Mock,
|
||||
mock_check_access: Mock,
|
||||
mcp_server: Any,
|
||||
) -> None:
|
||||
"""dataset_id pointing to a non-existent dataset returns error in
|
||||
preview path."""
|
||||
mock_chart = Mock()
|
||||
mock_chart.id = 55
|
||||
mock_chart.datasource_id = 10
|
||||
mock_chart.slice_name = "Old Chart"
|
||||
mock_chart.viz_type = "table"
|
||||
mock_chart.uuid = "uuid-55"
|
||||
mock_chart.params = None
|
||||
mock_chart_find.return_value = mock_chart
|
||||
|
||||
mock_check_access.return_value = DatasetValidationResult(
|
||||
is_valid=True,
|
||||
dataset_id=10,
|
||||
dataset_name="old_dataset",
|
||||
warnings=[],
|
||||
)
|
||||
|
||||
# Target dataset does not exist
|
||||
mock_dataset_find.return_value = None
|
||||
|
||||
request = {
|
||||
"identifier": 55,
|
||||
"dataset_id": 9999,
|
||||
"generate_preview": True,
|
||||
}
|
||||
|
||||
async with Client(mcp) as client:
|
||||
result = await client.call_tool("update_chart", {"request": request})
|
||||
|
||||
assert result.structured_content["success"] is False
|
||||
error_type = result.structured_content["error"]["error_type"]
|
||||
assert error_type == "DatasetNotAccessible"
|
||||
assert "9999" in result.structured_content["error"]["details"]
|
||||
|
||||
@@ -206,9 +206,12 @@ def test_group_api_post_delete_logs_event(mock_log: MagicMock) -> None:
|
||||
# --- Login / Logout ---
|
||||
|
||||
|
||||
@patch("superset.security.session_invalidation.stamp_login_time")
|
||||
@patch("superset.security.manager._log_audit_event")
|
||||
def test_on_user_login_logs_event(mock_log: MagicMock) -> None:
|
||||
"""on_user_login logs a UserLoggedIn event."""
|
||||
def test_on_user_login_logs_event(
|
||||
mock_log: MagicMock, mock_stamp_login_time: MagicMock
|
||||
) -> None:
|
||||
"""on_user_login logs a UserLoggedIn event and stamps the session."""
|
||||
sm = SupersetSecurityManager.__new__(SupersetSecurityManager)
|
||||
user = MagicMock(spec=User)
|
||||
user.username = "testuser"
|
||||
@@ -216,6 +219,7 @@ def test_on_user_login_logs_event(mock_log: MagicMock) -> None:
|
||||
|
||||
sm.on_user_login(user)
|
||||
|
||||
mock_stamp_login_time.assert_called_once()
|
||||
mock_log.assert_called_once_with(
|
||||
"UserLoggedIn", {"username": "testuser", "user_id": 7}
|
||||
)
|
||||
|
||||
@@ -1619,3 +1619,76 @@ def test_user_view_menu_names_for_guest_user_no_roles(
|
||||
|
||||
assert result == set()
|
||||
mock_get_user_id.assert_not_called()
|
||||
|
||||
|
||||
def test_reset_password_self_service_clears_flag(
|
||||
mocker: MockerFixture,
|
||||
app_context: None,
|
||||
) -> None:
|
||||
"""A user resetting their own password clears the forced-change flag."""
|
||||
sm = SupersetSecurityManager(appbuilder)
|
||||
# The target user (id 5) is the same as the acting user -> self-service.
|
||||
mock_g = SimpleNamespace(user=SimpleNamespace(id=5))
|
||||
mocker.patch("superset.security.manager.g", new=mock_g)
|
||||
# Avoid touching the real DB in the FAB base implementation.
|
||||
mocker.patch(
|
||||
"flask_appbuilder.security.manager.BaseSecurityManager.reset_password",
|
||||
return_value=None,
|
||||
)
|
||||
mock_clear = mocker.patch(
|
||||
"superset.security.password_change.clear_password_must_change"
|
||||
)
|
||||
|
||||
sm.reset_password(5, "new-password")
|
||||
|
||||
mock_clear.assert_called_once_with(5)
|
||||
|
||||
|
||||
def test_reset_password_admin_does_not_clear_flag(
|
||||
mocker: MockerFixture,
|
||||
app_context: None,
|
||||
) -> None:
|
||||
"""An admin-initiated reset must preserve the forced-change requirement.
|
||||
|
||||
FAB's ``ResetPasswordView`` passes the target as the ``pk`` request-arg
|
||||
string while ``g.user`` remains the admin, so the acting user differs from
|
||||
the target and the flag must NOT be cleared.
|
||||
"""
|
||||
sm = SupersetSecurityManager(appbuilder)
|
||||
# Acting user is admin (id 1); target is a different user ("5" as a string,
|
||||
# as FAB passes it from request args).
|
||||
mock_g = SimpleNamespace(user=SimpleNamespace(id=1))
|
||||
mocker.patch("superset.security.manager.g", new=mock_g)
|
||||
mocker.patch(
|
||||
"flask_appbuilder.security.manager.BaseSecurityManager.reset_password",
|
||||
return_value=None,
|
||||
)
|
||||
mock_clear = mocker.patch(
|
||||
"superset.security.password_change.clear_password_must_change"
|
||||
)
|
||||
|
||||
sm.reset_password("5", "temp-password")
|
||||
|
||||
mock_clear.assert_not_called()
|
||||
|
||||
|
||||
def test_reset_password_self_service_pk_string_clears_flag(
|
||||
mocker: MockerFixture,
|
||||
app_context: None,
|
||||
) -> None:
|
||||
"""Self-service identity holds even if ids arrive as mixed int/str types."""
|
||||
sm = SupersetSecurityManager(appbuilder)
|
||||
mock_g = SimpleNamespace(user=SimpleNamespace(id=5))
|
||||
mocker.patch("superset.security.manager.g", new=mock_g)
|
||||
mocker.patch(
|
||||
"flask_appbuilder.security.manager.BaseSecurityManager.reset_password",
|
||||
return_value=None,
|
||||
)
|
||||
mock_clear = mocker.patch(
|
||||
"superset.security.password_change.clear_password_must_change"
|
||||
)
|
||||
|
||||
sm.reset_password("5", "new-password")
|
||||
|
||||
# Coerced to int when clearing, regardless of the inbound id type.
|
||||
mock_clear.assert_called_once_with(5)
|
||||
|
||||
157
tests/unit_tests/security/test_guest_token_revocation.py
Normal file
157
tests/unit_tests/security/test_guest_token_revocation.py
Normal file
@@ -0,0 +1,157 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from superset.security.manager import SupersetSecurityManager
|
||||
|
||||
_DASHBOARD_RESOURCE = {"type": "dashboard", "id": "abc-uuid"}
|
||||
|
||||
|
||||
def _token(iat: int) -> dict[str, Any]:
|
||||
return {"type": "guest", "iat": iat, "resources": [_DASHBOARD_RESOURCE]}
|
||||
|
||||
|
||||
def _embedded(revoked_before) -> MagicMock:
|
||||
embedded = MagicMock()
|
||||
embedded.guest_token_revoked_before = revoked_before
|
||||
return embedded
|
||||
|
||||
|
||||
def test_guest_token_not_revoked_when_no_revocation_set() -> None:
|
||||
with patch(
|
||||
"superset.daos.dashboard.EmbeddedDashboardDAO.find_by_id",
|
||||
return_value=_embedded(None),
|
||||
):
|
||||
assert SupersetSecurityManager._is_guest_token_revoked(_token(1000)) is False
|
||||
|
||||
|
||||
def test_guest_token_revoked_when_issued_before_revocation() -> None:
|
||||
with patch(
|
||||
"superset.daos.dashboard.EmbeddedDashboardDAO.find_by_id",
|
||||
return_value=_embedded(2000),
|
||||
):
|
||||
# Token issued at 1000, revocation at 2000 -> revoked.
|
||||
assert SupersetSecurityManager._is_guest_token_revoked(_token(1000)) is True
|
||||
|
||||
|
||||
def test_guest_token_valid_when_issued_after_revocation() -> None:
|
||||
with patch(
|
||||
"superset.daos.dashboard.EmbeddedDashboardDAO.find_by_id",
|
||||
return_value=_embedded(2000),
|
||||
):
|
||||
# Token issued at 3000, after the revocation cutoff -> still valid.
|
||||
assert SupersetSecurityManager._is_guest_token_revoked(_token(3000)) is False
|
||||
|
||||
|
||||
def test_guest_token_without_iat_is_revoked_when_cutoff_set() -> None:
|
||||
# A token lacking ``iat`` cannot prove it predates the cutoff, so it
|
||||
# fails closed and is treated as revoked when a cutoff is configured.
|
||||
token = {"type": "guest", "resources": [_DASHBOARD_RESOURCE]}
|
||||
with patch(
|
||||
"superset.daos.dashboard.EmbeddedDashboardDAO.find_by_id",
|
||||
return_value=_embedded(2000),
|
||||
):
|
||||
assert SupersetSecurityManager._is_guest_token_revoked(token) is True
|
||||
|
||||
|
||||
def test_guest_token_without_iat_is_not_revoked_when_no_cutoff() -> None:
|
||||
token = {"type": "guest", "resources": [_DASHBOARD_RESOURCE]}
|
||||
with patch(
|
||||
"superset.daos.dashboard.EmbeddedDashboardDAO.find_by_id",
|
||||
return_value=_embedded(None),
|
||||
):
|
||||
assert SupersetSecurityManager._is_guest_token_revoked(token) is False
|
||||
|
||||
|
||||
def test_guest_token_revoked_via_legacy_dashboard_id_resource() -> None:
|
||||
# During the UUID migration a dashboard resource id may be a legacy
|
||||
# dashboard id rather than an embedded UUID. In that case the embedded
|
||||
# config is resolved via Dashboard.get(...).embedded, and its revocation
|
||||
# cutoff must still be honored.
|
||||
dashboard = MagicMock()
|
||||
dashboard.embedded = [_embedded(2000)]
|
||||
with (
|
||||
patch(
|
||||
"superset.daos.dashboard.EmbeddedDashboardDAO.find_by_id",
|
||||
return_value=None,
|
||||
),
|
||||
patch(
|
||||
"superset.models.dashboard.Dashboard.get",
|
||||
return_value=dashboard,
|
||||
),
|
||||
):
|
||||
assert SupersetSecurityManager._is_guest_token_revoked(_token(1000)) is True
|
||||
|
||||
|
||||
def test_guest_token_not_revoked_when_resource_unresolvable() -> None:
|
||||
# If neither an embedded config nor a dashboard resolves, there is no
|
||||
# cutoff to enforce and the token is treated as not revoked.
|
||||
with (
|
||||
patch(
|
||||
"superset.daos.dashboard.EmbeddedDashboardDAO.find_by_id",
|
||||
return_value=None,
|
||||
),
|
||||
patch(
|
||||
"superset.models.dashboard.Dashboard.get",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
assert SupersetSecurityManager._is_guest_token_revoked(_token(1000)) is False
|
||||
|
||||
|
||||
def _manager() -> SupersetSecurityManager:
|
||||
# Build an instance without running the (heavy) FAB __init__: we only
|
||||
# exercise revoke_guest_token_access, which depends on nothing but
|
||||
# _get_current_epoch_time and the EmbeddedDashboardDAO lookup.
|
||||
return SupersetSecurityManager.__new__(SupersetSecurityManager)
|
||||
|
||||
|
||||
def test_revoke_guest_token_access_uses_explicit_before() -> None:
|
||||
embedded = _embedded(None)
|
||||
with patch(
|
||||
"superset.daos.dashboard.EmbeddedDashboardDAO.find_by_id",
|
||||
return_value=embedded,
|
||||
):
|
||||
_manager().revoke_guest_token_access("abc-uuid", before=1234)
|
||||
assert embedded.guest_token_revoked_before == 1234
|
||||
|
||||
|
||||
def test_revoke_guest_token_access_defaults_to_ceil_of_now() -> None:
|
||||
embedded = _embedded(None)
|
||||
manager = _manager()
|
||||
with (
|
||||
patch(
|
||||
"superset.daos.dashboard.EmbeddedDashboardDAO.find_by_id",
|
||||
return_value=embedded,
|
||||
),
|
||||
patch.object(manager, "_get_current_epoch_time", return_value=1000.25),
|
||||
):
|
||||
manager.revoke_guest_token_access("abc-uuid")
|
||||
# Cutoff is rounded up so fractional-``iat`` tokens issued in the same
|
||||
# second are reliably revoked (fails closed).
|
||||
assert embedded.guest_token_revoked_before == 1001
|
||||
|
||||
|
||||
def test_revoke_guest_token_access_noop_when_embedded_missing() -> None:
|
||||
with patch(
|
||||
"superset.daos.dashboard.EmbeddedDashboardDAO.find_by_id",
|
||||
return_value=None,
|
||||
):
|
||||
# Should simply return without raising when the UUID does not resolve.
|
||||
_manager().revoke_guest_token_access("missing-uuid")
|
||||
223
tests/unit_tests/security/test_password_change.py
Normal file
223
tests/unit_tests/security/test_password_change.py
Normal file
@@ -0,0 +1,223 @@
|
||||
# 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 collections.abc import Iterator
|
||||
from typing import Optional
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
from superset.security.password_change import (
|
||||
_get_user_attribute,
|
||||
_is_exempt_endpoint,
|
||||
password_change_required,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"endpoint,expected",
|
||||
[
|
||||
(None, True), # static file serving etc.
|
||||
("ResetMyPasswordView.this_form_get", True),
|
||||
("AuthDBView.login", True),
|
||||
("AuthDBView.logout", True),
|
||||
("appbuilder.static", True),
|
||||
("UserInfoEditView.this_form_post", True),
|
||||
("AuthOAuthView.login", True),
|
||||
("SomeBlueprint.static", True),
|
||||
("health", True),
|
||||
("SupersetIndexView.index", False),
|
||||
("Superset.dashboard", False),
|
||||
# Substring over-matching must NOT exempt these (they merely share a
|
||||
# substring with an exempt token).
|
||||
("AuthorView.list", False),
|
||||
("HealthDashboardView.show", False),
|
||||
("StaticAssetReportView.list", False),
|
||||
("UserInfoFancyView.show", False),
|
||||
],
|
||||
)
|
||||
def test_is_exempt_endpoint(endpoint: Optional[str], expected: bool) -> None:
|
||||
"""Exempt-endpoint matching is exact per view class, never substring."""
|
||||
# The password-reset / auth / static endpoints must stay reachable to avoid
|
||||
# a redirect loop while a change is pending.
|
||||
assert _is_exempt_endpoint(endpoint) is expected
|
||||
|
||||
|
||||
def test_password_change_required() -> None:
|
||||
"""The flag on the user's attribute row drives the required-change check."""
|
||||
user = MagicMock()
|
||||
user.id = 5
|
||||
|
||||
with patch(
|
||||
"superset.security.password_change._get_user_attribute"
|
||||
) as mock_get_attr:
|
||||
mock_get_attr.return_value = MagicMock(password_must_change=True)
|
||||
assert password_change_required(user) is True
|
||||
|
||||
mock_get_attr.return_value = MagicMock(password_must_change=False)
|
||||
assert password_change_required(user) is False
|
||||
|
||||
mock_get_attr.return_value = None
|
||||
assert password_change_required(user) is False
|
||||
|
||||
|
||||
def test_password_change_required_no_user_id() -> None:
|
||||
"""A user without an id (e.g. anonymous) never requires a change."""
|
||||
user = MagicMock()
|
||||
user.id = None
|
||||
assert password_change_required(user) is False
|
||||
|
||||
|
||||
def test_get_user_attribute_deterministic_with_duplicates() -> None:
|
||||
"""Duplicate attribute rows must yield a deterministic row, not a 500."""
|
||||
# Databases migrated from before the ``user_attribute.user_id`` unique
|
||||
# constraint could contain duplicate rows. The query must not raise (which
|
||||
# ``.one_or_none()`` would have done via ``MultipleResultsFound``); it must
|
||||
# fetch a single row deterministically via ``order_by(id).first()``.
|
||||
query = MagicMock()
|
||||
db = MagicMock()
|
||||
db.session.query.return_value = query
|
||||
query.filter.return_value = query
|
||||
query.order_by.return_value = query
|
||||
sentinel = MagicMock(name="first_row")
|
||||
query.first.return_value = sentinel
|
||||
|
||||
with (
|
||||
patch("superset.extensions.db", db),
|
||||
patch("superset.models.user_attributes.UserAttribute") as user_attribute,
|
||||
):
|
||||
result = _get_user_attribute(5)
|
||||
|
||||
# Deterministic ordering on the primary key, then ``.first()`` — never
|
||||
# ``.one_or_none()``, which could 500 on duplicate rows.
|
||||
query.order_by.assert_called_once_with(user_attribute.id)
|
||||
query.first.assert_called_once_with()
|
||||
assert not query.one_or_none.called
|
||||
assert result is sentinel
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def enforcement_app() -> Flask:
|
||||
"""A minimal Flask app with the enforcement hook registered and a flagged
|
||||
user, used to exercise the before-request redirect behavior end to end."""
|
||||
from flask import g
|
||||
|
||||
from superset.security.password_change import (
|
||||
register_password_change_enforcement,
|
||||
)
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config["ENABLE_FORCE_PASSWORD_CHANGE"] = True
|
||||
app.secret_key = "test" # noqa: S105
|
||||
|
||||
user = MagicMock()
|
||||
user.id = 5
|
||||
user.is_anonymous = False
|
||||
|
||||
@app.before_request
|
||||
def _set_user() -> None: # pylint: disable=unused-variable
|
||||
g.user = user
|
||||
|
||||
# A non-exempt route that, if redirected to, would re-trigger enforcement.
|
||||
@app.route("/")
|
||||
def index() -> str: # pylint: disable=unused-variable
|
||||
return "index"
|
||||
|
||||
register_password_change_enforcement(app)
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_babel_flash() -> Iterator[None]:
|
||||
"""The minimal test app has no babel/flash messaging set up; stub them so
|
||||
the enforcement hook's translation + flash calls don't blow up. These are
|
||||
incidental to the redirect-target logic under test."""
|
||||
with (
|
||||
patch("superset.security.password_change.flash"),
|
||||
patch("superset.security.password_change.__", side_effect=lambda s: s),
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
def test_enforcement_redirects_to_reset_view(enforcement_app: Flask) -> None:
|
||||
# Happy path: the reset endpoint resolves, so flagged users are redirected
|
||||
# there (an exempt route) — no loop.
|
||||
with (
|
||||
patch(
|
||||
"superset.security.password_change.password_change_required",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"superset.security.password_change.url_for",
|
||||
return_value="/resetmypassword/form",
|
||||
),
|
||||
):
|
||||
resp = enforcement_app.test_client().get("/")
|
||||
assert resp.status_code == 302
|
||||
assert resp.headers["Location"].endswith("/resetmypassword/form")
|
||||
|
||||
|
||||
def test_enforcement_falls_back_to_exempt_logout_not_index(
|
||||
enforcement_app: Flask,
|
||||
) -> None:
|
||||
# If the reset endpoint can't be resolved, the fallback must be an exempt
|
||||
# route (logout) — never "/" / the index, which would loop. We make the
|
||||
# reset endpoint fail and the logout endpoint resolve.
|
||||
def fake_url_for(endpoint: str, *args, **kwargs) -> str:
|
||||
if endpoint == "ResetMyPasswordView.this_form_get":
|
||||
raise RuntimeError("no such endpoint")
|
||||
if endpoint == "AuthDBView.logout":
|
||||
return "/logout"
|
||||
raise AssertionError(f"unexpected endpoint {endpoint}")
|
||||
|
||||
with (
|
||||
patch(
|
||||
"superset.security.password_change.password_change_required",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"superset.security.password_change.url_for",
|
||||
side_effect=fake_url_for,
|
||||
),
|
||||
):
|
||||
resp = enforcement_app.test_client().get("/")
|
||||
assert resp.status_code == 302
|
||||
location = resp.headers["Location"]
|
||||
assert location.endswith("/logout")
|
||||
# Crucially, the fallback is NOT a redirect back to the non-exempt index.
|
||||
assert not location.endswith("/")
|
||||
|
||||
|
||||
def test_enforcement_no_resolvable_target_returns_error_not_loop(
|
||||
enforcement_app: Flask,
|
||||
) -> None:
|
||||
# If NO exempt target can be resolved, we must return an error response
|
||||
# rather than redirect, so the flagged user can never get stuck in a loop.
|
||||
with (
|
||||
patch(
|
||||
"superset.security.password_change.password_change_required",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"superset.security.password_change.url_for",
|
||||
side_effect=RuntimeError("no endpoints"),
|
||||
),
|
||||
):
|
||||
resp = enforcement_app.test_client().get("/")
|
||||
assert resp.status_code == 503
|
||||
assert "Location" not in resp.headers
|
||||
214
tests/unit_tests/security/test_session_invalidation.py
Normal file
214
tests/unit_tests/security/test_session_invalidation.py
Normal file
@@ -0,0 +1,214 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from superset.security.session_invalidation import (
|
||||
_as_utc_timestamp,
|
||||
enforce_session_validity,
|
||||
invalidate_user_sessions,
|
||||
is_session_invalidated,
|
||||
SESSION_LOGIN_AT_KEY,
|
||||
)
|
||||
|
||||
|
||||
def test_no_epoch_is_never_invalidated() -> None:
|
||||
"""A user that was never disabled (NULL epoch) is never invalidated."""
|
||||
assert is_session_invalidated(login_at=None, invalidated_at=None) is False
|
||||
assert is_session_invalidated(login_at=1_000.0, invalidated_at=None) is False
|
||||
|
||||
|
||||
def test_epoch_with_no_login_time_fails_closed() -> None:
|
||||
"""A pre-feature session (no _login_at) on a disabled user is invalidated."""
|
||||
epoch = datetime.now(timezone.utc)
|
||||
assert is_session_invalidated(login_at=None, invalidated_at=epoch) is True
|
||||
|
||||
|
||||
def test_session_before_epoch_is_invalidated() -> None:
|
||||
epoch = datetime.now(timezone.utc)
|
||||
before = (epoch - timedelta(minutes=5)).timestamp()
|
||||
assert is_session_invalidated(login_at=before, invalidated_at=epoch) is True
|
||||
|
||||
|
||||
def test_session_after_epoch_is_valid() -> None:
|
||||
"""A fresh login after a disable+re-enable must not be invalidated."""
|
||||
epoch = datetime.now(timezone.utc)
|
||||
after = (epoch + timedelta(minutes=5)).timestamp()
|
||||
assert is_session_invalidated(login_at=after, invalidated_at=epoch) is False
|
||||
|
||||
|
||||
def test_login_exactly_at_epoch_is_valid() -> None:
|
||||
epoch = datetime.now(timezone.utc)
|
||||
assert (
|
||||
is_session_invalidated(login_at=epoch.timestamp(), invalidated_at=epoch)
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
def test_naive_epoch_is_treated_as_utc() -> None:
|
||||
"""
|
||||
The DB column is a naive UTC ``DateTime``; the comparison must treat it as
|
||||
UTC, not local time (otherwise it skews by the local offset).
|
||||
"""
|
||||
aware = datetime(2026, 6, 2, 12, 0, 0, tzinfo=timezone.utc)
|
||||
naive = aware.replace(tzinfo=None)
|
||||
assert _as_utc_timestamp(naive) == aware.timestamp()
|
||||
|
||||
just_before = aware.timestamp() - 1
|
||||
just_after = aware.timestamp() + 1
|
||||
assert is_session_invalidated(login_at=just_before, invalidated_at=naive) is True
|
||||
assert is_session_invalidated(login_at=just_after, invalidated_at=naive) is False
|
||||
|
||||
|
||||
MODULE = "superset.security.session_invalidation"
|
||||
|
||||
|
||||
def _user(*, authenticated: bool = True, guest: bool = False) -> SimpleNamespace:
|
||||
return SimpleNamespace(is_authenticated=authenticated, is_guest_user=guest)
|
||||
|
||||
|
||||
def test_enforce_skips_unauthenticated_user() -> None:
|
||||
"""No authenticated user ⇒ nothing to enforce, request proceeds."""
|
||||
with (
|
||||
patch(f"{MODULE}.current_user", _user(authenticated=False)),
|
||||
patch(f"{MODULE}.logout_user") as logout,
|
||||
):
|
||||
assert enforce_session_validity() is None
|
||||
logout.assert_not_called()
|
||||
|
||||
|
||||
def test_enforce_skips_guest_user() -> None:
|
||||
"""Guest (embedded) users have their own revocation path and are skipped."""
|
||||
with (
|
||||
patch(f"{MODULE}.current_user", _user(guest=True)),
|
||||
patch(f"{MODULE}._get_user_invalidated_at") as get_epoch,
|
||||
patch(f"{MODULE}.logout_user") as logout,
|
||||
):
|
||||
assert enforce_session_validity() is None
|
||||
get_epoch.assert_not_called()
|
||||
logout.assert_not_called()
|
||||
|
||||
|
||||
def test_enforce_no_epoch_leaves_session_alone() -> None:
|
||||
"""A user with no invalidation epoch is never logged out."""
|
||||
with (
|
||||
patch(f"{MODULE}.current_user", _user()),
|
||||
patch(f"{MODULE}._get_user_invalidated_at", return_value=None),
|
||||
patch(f"{MODULE}.logout_user") as logout,
|
||||
):
|
||||
assert enforce_session_validity() is None
|
||||
logout.assert_not_called()
|
||||
|
||||
|
||||
def test_enforce_valid_session_is_not_logged_out() -> None:
|
||||
"""A session that logged in after the epoch stays authenticated."""
|
||||
epoch = datetime.now(timezone.utc)
|
||||
after = (epoch + timedelta(minutes=5)).timestamp()
|
||||
fake_session = MagicMock()
|
||||
fake_session.get.return_value = after
|
||||
with (
|
||||
patch(f"{MODULE}.current_user", _user()),
|
||||
patch(f"{MODULE}._get_user_invalidated_at", return_value=epoch),
|
||||
patch(f"{MODULE}.session", fake_session),
|
||||
patch(f"{MODULE}.logout_user") as logout,
|
||||
):
|
||||
assert enforce_session_validity() is None
|
||||
fake_session.get.assert_called_once_with(SESSION_LOGIN_AT_KEY)
|
||||
logout.assert_not_called()
|
||||
|
||||
|
||||
def test_enforce_invalidated_session_is_logged_out() -> None:
|
||||
"""A session predating the epoch is cleared and flashed a warning."""
|
||||
epoch = datetime.now(timezone.utc)
|
||||
before = (epoch - timedelta(minutes=5)).timestamp()
|
||||
fake_session = MagicMock()
|
||||
fake_session.get.return_value = before
|
||||
with (
|
||||
patch(f"{MODULE}.current_user", _user()),
|
||||
patch(f"{MODULE}._get_user_invalidated_at", return_value=epoch),
|
||||
patch(f"{MODULE}.session", fake_session),
|
||||
patch(f"{MODULE}.logout_user") as logout,
|
||||
patch(f"{MODULE}.flash") as flash,
|
||||
):
|
||||
assert enforce_session_validity() is None
|
||||
logout.assert_called_once()
|
||||
fake_session.clear.assert_called_once()
|
||||
flash.assert_called_once()
|
||||
|
||||
|
||||
def test_enforce_fails_open_on_error() -> None:
|
||||
"""Any error in the check logs a warning and allows the request."""
|
||||
# A real (non-guest, authenticated) user so the check reaches the epoch
|
||||
# lookup, which then raises — exercising the fail-open handler.
|
||||
user = SimpleNamespace(is_authenticated=True, is_guest_user=False)
|
||||
with (
|
||||
patch(f"{MODULE}.current_user", user),
|
||||
patch(f"{MODULE}._get_user_invalidated_at", side_effect=RuntimeError("boom")),
|
||||
patch(f"{MODULE}.logout_user") as logout,
|
||||
patch(f"{MODULE}.logger") as logger,
|
||||
):
|
||||
assert enforce_session_validity() is None
|
||||
logout.assert_not_called()
|
||||
logger.warning.assert_called_once()
|
||||
|
||||
|
||||
def test_invalidate_updates_existing_row() -> None:
|
||||
"""When a row already exists, the upsert updates it and skips the insert."""
|
||||
connection = MagicMock()
|
||||
connection.execute.return_value.rowcount = 1
|
||||
invalidate_user_sessions(connection, user_id=7)
|
||||
# Single UPDATE, no INSERT / SAVEPOINT.
|
||||
assert connection.execute.call_count == 1
|
||||
connection.begin_nested.assert_not_called()
|
||||
|
||||
|
||||
def test_invalidate_inserts_when_missing() -> None:
|
||||
"""When no row exists, the upsert inserts one inside a SAVEPOINT."""
|
||||
connection = MagicMock()
|
||||
# First execute is the UPDATE (rowcount 0); second is the INSERT.
|
||||
connection.execute.return_value.rowcount = 0
|
||||
invalidate_user_sessions(connection, user_id=7)
|
||||
assert connection.execute.call_count == 2
|
||||
connection.begin_nested.assert_called_once()
|
||||
|
||||
|
||||
def test_invalidate_retries_as_update_on_race() -> None:
|
||||
"""If a concurrent disable wins the insert, the IntegrityError is caught
|
||||
and the row is stamped via UPDATE instead of duplicating it."""
|
||||
connection = MagicMock()
|
||||
update_result = SimpleNamespace(rowcount=0)
|
||||
retry_result = SimpleNamespace(rowcount=1)
|
||||
|
||||
calls: list[str] = []
|
||||
|
||||
def execute(statement, *args, **kwargs): # noqa: ANN001, ANN002, ANN003
|
||||
compiled = str(statement).strip().upper()
|
||||
if compiled.startswith("UPDATE"):
|
||||
calls.append("update")
|
||||
# First UPDATE finds nothing; the retry UPDATE succeeds.
|
||||
return retry_result if len(calls) > 1 else update_result
|
||||
calls.append("insert")
|
||||
raise IntegrityError("insert", {}, Exception())
|
||||
|
||||
connection.execute.side_effect = execute
|
||||
invalidate_user_sessions(connection, user_id=7)
|
||||
# update (miss) -> insert (race loses) -> update (retry succeeds)
|
||||
assert calls == ["update", "insert", "update"]
|
||||
Reference in New Issue
Block a user