Compare commits

..

3 Commits

Author SHA1 Message Date
Đỗ Trọng Hải
ebbbd6681c Merge branch 'master' into ci/only-run-e2e-cypress-on-merged-commit 2026-06-11 22:08:06 +07:00
hainenber
483c180298 feat: do not run expensive E2E on draft PR
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-06-04 22:31:16 +07:00
hainenber
2959ab855c feat(ci): only run expensive E2E tests on merged commits or manual dispatch
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-06-03 23:24:14 +07:00
23 changed files with 21 additions and 1819 deletions

View File

@@ -52,6 +52,7 @@ 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

View File

@@ -91,22 +91,6 @@ 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.
### SMTP server certificate validation enabled by default
`SMTP_SSL_SERVER_AUTH` now defaults to `True` (previously `False`). With this default, STARTTLS/SSL connections to the configured SMTP server validate the server's TLS certificate against the system trusted CA store. This makes outbound email (alerts and reports) verify the mail server's identity out of the box.
If your SMTP server presents a self-signed certificate, or a certificate that is not trusted by the system CA store, email delivery may now fail with a certificate verification error. To restore the previous behavior of skipping certificate validation, set the following in `superset_config.py`:
```python
SMTP_SSL_SERVER_AUTH = False
```
The recommended fix is to add the SMTP server's certificate (or its issuing CA) to the system trust store rather than disabling validation.
### Dataset import validates catalog against the target connection
Importing a dataset now validates the `catalog` field against the target database connection. When the connection has multi-catalog disabled (`allow_multi_catalog` off) and the dataset's catalog is not the connection's default catalog, the import fails instead of silently persisting the non-default catalog. This matches the validation already enforced on the dataset update path and prevents imported datasets from querying an unintended database.

View File

@@ -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, <4.0", # 4.0 removed DSSKey, still referenced by sshtunnel
"paramiko>=3.4.0",
"pgsanity",
"Pillow>=11.0.0, <13",
"polyline>=2.0.0, <3.0",

View File

@@ -74,30 +74,6 @@ 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' },
@@ -257,17 +233,6 @@ 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';

View File

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

View File

@@ -729,14 +729,6 @@ 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"""
@@ -772,23 +764,6 @@ 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

View File

@@ -1,47 +0,0 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""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")

View File

@@ -1,44 +0,0 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""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")

View File

@@ -1,177 +0,0 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""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)

View File

@@ -40,10 +40,6 @@ 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"),

View File

@@ -2289,7 +2289,7 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
time_grain: str,
time_offset: str | None = None,
) -> str:
value = row.iloc[column_index]
value = row[column_index]
if hasattr(value, "strftime"):
if time_offset and not ExploreMixin.is_valid_date_range_static(time_offset):

View File

@@ -16,15 +16,7 @@
# under the License.
from flask_appbuilder import Model
from sqlalchemy import (
Boolean,
Column,
DateTime,
ForeignKey,
Integer,
String,
UniqueConstraint,
)
from sqlalchemy import Column, ForeignKey, Integer, String
from sqlalchemy.orm import relationship
from superset import security_manager
@@ -42,9 +34,6 @@ 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(
@@ -53,12 +42,3 @@ 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)

View File

@@ -21,9 +21,8 @@ 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, Union
from typing import Any, Callable, cast, NamedTuple, Optional, TYPE_CHECKING
from flask import current_app, Flask, g, Request
from flask_appbuilder import Model
@@ -87,7 +86,6 @@ 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
@@ -640,56 +638,7 @@ 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},
@@ -3753,31 +3702,18 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
return self.get_guest_user_from_token(cast(GuestToken, token))
@classmethod
def _is_guest_token_revoked(cls, token: dict[str, Any]) -> bool:
"""
Determine whether a guest token has been revoked by any mechanism.
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``."""
def _is_guest_token_revoked(token: dict[str, Any]) -> bool:
"""
Determine whether a guest token has been revoked via a version bump.
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.
"""
if not get_conf()["GUEST_TOKEN_REVOCATION_ENABLED"]:
return False
token_version = token.get(
@@ -3789,67 +3725,6 @@ 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,

View File

@@ -1,211 +0,0 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""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,
)

View File

@@ -1,205 +0,0 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""
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)

View File

@@ -37,18 +37,9 @@ logger = logging.getLogger(__name__)
class TestEmailSmtp(SupersetTestCase):
SMTP_CONFIG_KEYS = ("SMTP_SSL", "SMTP_SSL_SERVER_AUTH", "SMTP_STARTTLS")
def setUp(self):
self._original_smtp_config = {
key: current_app.config[key] for key in self.SMTP_CONFIG_KEYS
}
current_app.config["SMTP_SSL"] = False
def tearDown(self):
current_app.config.update(self._original_smtp_config)
super().tearDown()
@mock.patch("superset.utils.core.send_mime_email")
def test_send_smtp(self, mock_send_mime):
attachment = tempfile.NamedTemporaryFile()
@@ -217,7 +208,6 @@ class TestEmailSmtp(SupersetTestCase):
@mock.patch("smtplib.SMTP")
def test_send_mime_ssl(self, mock_smtp, mock_smtp_ssl):
current_app.config["SMTP_SSL"] = True
current_app.config["SMTP_SSL_SERVER_AUTH"] = False
mock_smtp.return_value = mock.Mock()
mock_smtp_ssl.return_value = mock.Mock()
utils.send_mime_email(

View File

@@ -1,79 +0,0 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from 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

View File

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

View File

@@ -206,12 +206,9 @@ 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, mock_stamp_login_time: MagicMock
) -> None:
"""on_user_login logs a UserLoggedIn event and stamps the session."""
def test_on_user_login_logs_event(mock_log: MagicMock) -> None:
"""on_user_login logs a UserLoggedIn event."""
sm = SupersetSecurityManager.__new__(SupersetSecurityManager)
user = MagicMock(spec=User)
user.username = "testuser"
@@ -219,7 +216,6 @@ def test_on_user_login_logs_event(
sm.on_user_login(user)
mock_stamp_login_time.assert_called_once()
mock_log.assert_called_once_with(
"UserLoggedIn", {"username": "testuser", "user_id": 7}
)

View File

@@ -1619,76 +1619,3 @@ 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)

View File

@@ -1,157 +0,0 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from 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")

View File

@@ -1,223 +0,0 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from 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

View File

@@ -1,214 +0,0 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from 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"]