Compare commits

...
Author SHA1 Message Date
rusackasandClaude Sonnet 5 462eb52ba8 fix(dashboard): include native filter names in filter_state GET response
The cached filter_state value is a DataMaskStateWithId keyed by filter id,
not a flat object, so a client can't reliably read a "name"/label out of
it. Cross-reference the filter ids present in the cached value against the
dashboard's native_filter_configuration and return a `names` map (filter
id -> label) alongside `value`.

Fixes #36053

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 20:53:08 -07:00
Evan RusackasandClaude Sonnet 5 daf41bdf04 fix(security): suppress zizmor self-repository false positive for sqlite install-deps step (#44002)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 18:25:00 -07:00
4 changed files with 120 additions and 4 deletions
@@ -212,7 +212,13 @@ jobs:
- name: Setup Python
uses: $/.github/actions/setup-backend/
- name: Install dependencies
uses: ./.github/actions/cached-dependencies
# cached-dependencies is a git submodule (not a plain directory), and
# the $/ self-repository syntax resolves action files directly from
# the repository without performing a real (submodule-aware)
# checkout, so it can't see into a submodule's gitlink. Keep this one
# on the workspace-relative ./ form, consistent with every other
# workflow in the repo that references this action.
uses: ./.github/actions/cached-dependencies # zizmor: ignore[self-repository] - $/ cannot resolve an action that lives in a submodule; ./ is required here
with:
run: |
# sqlite needs this working directory
@@ -14,15 +14,17 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from typing import Optional
from typing import Any, Optional
from flask import current_app as app
from superset.commands.dashboard.filter_state.utils import check_access
from superset.commands.temporary_cache.get import GetTemporaryCacheCommand
from superset.commands.temporary_cache.parameters import CommandParameters
from superset.daos.dashboard import DashboardDAO
from superset.extensions import cache_manager
from superset.temporary_cache.utils import cache_key
from superset.utils import json
class GetFilterStateCommand(GetTemporaryCacheCommand):
@@ -40,3 +42,48 @@ class GetFilterStateCommand(GetTemporaryCacheCommand):
if entry and self._refresh_timeout:
cache_manager.filter_state_cache.set(key, entry)
return entry.get("value")
@staticmethod
def get_filter_names(resource_id: int, value: Optional[str]) -> dict[str, str]:
"""
Cross-reference the filter ids present in a cached filter_state
``value`` (a ``DataMaskStateWithId``, i.e. a map keyed by filter id)
against the dashboard's ``native_filter_configuration`` to build a
map of filter id -> human-readable filter label.
The cached blob itself has no notion of a filter's label: that only
lives in the dashboard's filter configuration metadata (#36053).
"""
if not value:
return {}
try:
parsed_value = json.loads(value)
except (json.JSONDecodeError, TypeError):
return {}
if not isinstance(parsed_value, dict):
return {}
dashboard = DashboardDAO.get_by_id_or_slug(str(resource_id))
try:
metadata = json.loads(dashboard.json_metadata or "{}")
except (json.JSONDecodeError, TypeError):
return {}
if not isinstance(metadata, dict):
return {}
native_filters: list[dict[str, Any]] = metadata.get(
"native_filter_configuration", []
)
id_to_name = {
native_filter["id"]: native_filter["name"]
for native_filter in native_filters
if isinstance(native_filter, dict)
and native_filter.get("id") is not None
and native_filter.get("name") is not None
}
return {
filter_id: id_to_name[filter_id]
for filter_id in parsed_value
if filter_id in id_to_name
}
+26 -2
View File
@@ -23,8 +23,13 @@ from superset.commands.dashboard.filter_state.create import CreateFilterStateCom
from superset.commands.dashboard.filter_state.delete import DeleteFilterStateCommand
from superset.commands.dashboard.filter_state.get import GetFilterStateCommand
from superset.commands.dashboard.filter_state.update import UpdateFilterStateCommand
from superset.commands.temporary_cache.exceptions import (
TemporaryCacheAccessDeniedError,
TemporaryCacheResourceNotFoundError,
)
from superset.commands.temporary_cache.parameters import CommandParameters
from superset.extensions import event_logger
from superset.temporary_cache.api import TemporaryCacheRestApi
from superset.temporary_cache.api import CODEC, TemporaryCacheRestApi
logger = logging.getLogger(__name__)
@@ -256,6 +261,14 @@ class DashboardFilterStateRestApi(TemporaryCacheRestApi):
value:
type: string
description: The stored value
names:
type: object
description: >-
A map of native filter id to that filter's
human-readable label, for the filter ids present in
`value`. Cross-referenced from the dashboard's
native filter configuration, since the cached
`value` itself has no notion of a filter's label.
400:
$ref: '#/components/responses/400'
401:
@@ -267,7 +280,18 @@ class DashboardFilterStateRestApi(TemporaryCacheRestApi):
500:
$ref: '#/components/responses/500'
"""
return super().get(pk, key)
try:
args = CommandParameters(resource_id=pk, key=key, codec=CODEC)
command = self.get_get_command()(args)
value = command.run()
if not value:
return self.response_404()
names = command.get_filter_names(pk, value)
return self.response(200, value=value, names=names)
except TemporaryCacheAccessDeniedError as ex:
return self.response(403, message=str(ex))
except TemporaryCacheResourceNotFoundError as ex:
return self.response(404, message=str(ex))
@expose("/<int:pk>/filter_state/<string:key>", methods=("DELETE",))
@protect()
@@ -317,6 +317,45 @@ def test_get_dashboard_filter_state(test_client, login_as_admin, dashboard_id: i
assert INITIAL_VALUE == data.get("value")
def test_get_dashboard_filter_state_includes_names(
test_client, login_as_admin, dashboard_id: int, admin_id: int
):
"""
The cached filter state is a DataMaskStateWithId, i.e. a map keyed by
filter id, not a flat object with a top-level "name" (#36053). The
human-readable label for each filter id lives in the dashboard's
native_filter_configuration, so the GET response should cross-reference
it and return a `names` map of filter id -> label alongside `value`.
"""
dashboard = db.session.query(Dashboard).filter_by(id=dashboard_id).one()
original_json_metadata = dashboard.json_metadata
filter_id = "NATIVE_FILTER-abc123"
try:
metadata = json.loads(dashboard.json_metadata or "{}")
metadata["native_filter_configuration"] = [
{"id": filter_id, "name": "My Filter Label"}
]
dashboard.json_metadata = json.dumps(metadata)
db.session.commit()
filter_state_value = json.dumps(
{filter_id: {"id": filter_id, "extraFormData": {}}}
)
cache_manager.filter_state_cache.set(
cache_key(dashboard_id, KEY),
{"owner": admin_id, "value": filter_state_value},
)
resp = test_client.get(f"api/v1/dashboard/{dashboard_id}/filter_state/{KEY}")
assert resp.status_code == 200
data = json.loads(resp.data.decode("utf-8"))
assert data.get("value") == filter_state_value
assert data.get("names") == {filter_id: "My Filter Label"}
finally:
dashboard.json_metadata = original_json_metadata
db.session.commit()
def test_get_access_denied(test_client, login_as, dashboard_id):
login_as("gamma")
resp = test_client.get(f"api/v1/dashboard/{dashboard_id}/filter_state/{KEY}")