mirror of
https://github.com/apache/superset.git
synced 2026-07-26 00:22:31 +00:00
Co-authored-by: Mike Bridge <michael.bridge@ext.preset.io> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
207 lines
8.6 KiB
Python
207 lines
8.6 KiB
Python
# 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.
|
|
"""Silent visibility filter for activity-view records.
|
|
|
|
Drops records whose source entity the requester can't read. Silent in
|
|
the sense that dropped records contribute no count and no placeholder
|
|
to the response — the user sees only what they're entitled to see, and
|
|
the response shape can't be used to infer the existence of entities
|
|
they're gated out of.
|
|
|
|
Visibility is resolved SQL-side via each resource's existing FAB
|
|
access filter (``DashboardAccessFilter`` / ``ChartFilter`` /
|
|
``DatasourceFilter``). Two SQL queries per kind (one for live ids, one
|
|
for the access-filtered subset) replace the N-call
|
|
``security_manager.can_access_<kind>(entity)`` loop that dominated
|
|
latency on dashboard-scope responses with many related entities
|
|
(sqlalchemy-review W-NEW-1).
|
|
|
|
**Inline imports.** ``_resolve_visibility`` defers the FAB-filter
|
|
imports (``DashboardAccessFilter`` / ``ChartFilter`` /
|
|
``DatasourceFilter`` and ``SQLAInterface``) until call time. Same
|
|
init-order rationale as :mod:`superset.versioning.baseline` —
|
|
``versioning`` packages are imported from ``init_versioning()``
|
|
before all model mappers are configured, and the filter classes pull
|
|
in their resource's model graph (Chart → Dataset → Database for
|
|
``ChartFilter``, etc.); a module-top import would trip mapper
|
|
resolution before Continuum's ``make_versioned()`` has finished.
|
|
|
|
**Integration shape.** The activity-view consumes FAB's access-filter
|
|
classes (``DashboardAccessFilter`` / ``ChartFilter`` /
|
|
``DatasourceFilter``) directly rather than translating them or
|
|
re-implementing the predicate. That keeps the activity stream's
|
|
visibility posture identical to the list endpoints' — operator-
|
|
controlled and stable — at the cost of coupling to FAB's exact
|
|
filter shape. Future entities added to the activity surface must
|
|
extend the dispatch table in ``_resolve_visibility`` to include
|
|
their access-filter class.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import Any
|
|
|
|
from superset.extensions import db
|
|
from superset.versioning.activity.kinds import (
|
|
chunked_ids,
|
|
load_live_model,
|
|
NAME_COLUMN,
|
|
TABLE_KIND_TO_API,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def filter_records_by_visibility(
|
|
records: list[dict[str, Any]],
|
|
) -> list[dict[str, Any]]:
|
|
"""Drop records whose source entity the requester can't read.
|
|
|
|
The filter is silent: dropped records contribute no count
|
|
and no placeholder. Tombstoned entities (no live row) pass through
|
|
— the decorator step marks them ``entity_deleted: true`` and the
|
|
payload exposes no navigable ``entity_uuid``, so there's nothing
|
|
sensitive left to gate.
|
|
|
|
Visibility is resolved SQL-side via each resource's existing access
|
|
filter, which reads the requesting user from Flask-Login internally
|
|
(no explicit user parameter threads through here). If a CLI/Celery
|
|
bypass becomes necessary in the future, add it then with a real call
|
|
site.
|
|
"""
|
|
if not records:
|
|
return records
|
|
|
|
distinct: set[tuple[str, int]] = {
|
|
(
|
|
TABLE_KIND_TO_API.get(r["entity_kind"], r["entity_kind"]),
|
|
r["entity_id"],
|
|
)
|
|
for r in records
|
|
}
|
|
visible = _resolve_visibility(distinct)
|
|
return [
|
|
r
|
|
for r in records
|
|
if visible.get(
|
|
(
|
|
TABLE_KIND_TO_API.get(r["entity_kind"], r["entity_kind"]),
|
|
r["entity_id"],
|
|
),
|
|
True, # tombstone / unknown kind → pass through
|
|
)
|
|
]
|
|
|
|
|
|
def _resolve_visibility(
|
|
distinct_entities: set[tuple[str, int]],
|
|
) -> dict[tuple[str, int], bool]:
|
|
"""Return ``{(api_kind, entity_id): can_read}`` for the live row of
|
|
each entity. Missing live rows (tombstoned) map to ``True`` — the
|
|
decorator handles the deleted-state messaging separately.
|
|
|
|
Visibility is computed SQL-side via each resource's existing access
|
|
filter (``DashboardAccessFilter`` / ``ChartFilter`` /
|
|
``DatasourceFilter``). These are the same filters FAB's
|
|
``ModelRestApi`` applies to ``base_filters`` on list endpoints, so
|
|
the activity-view visibility check matches the rest of the read
|
|
surface byte-for-byte. Two queries per kind (one for live ids, one
|
|
for the access-filtered subset) replace the N-call
|
|
``security_manager.can_access_<kind>(entity)`` loop that dominated
|
|
latency on dashboard-scope activity responses with many related
|
|
entities (sqlalchemy-review W-NEW-1).
|
|
"""
|
|
# pylint: disable=import-outside-toplevel
|
|
from flask_appbuilder.models.sqla.interface import SQLAInterface
|
|
|
|
from superset.charts.filters import ChartFilter
|
|
from superset.dashboards.filters import DashboardAccessFilter
|
|
from superset.views.base import DatasourceFilter
|
|
|
|
access_filter_classes: dict[str, type] = {
|
|
"Dashboard": DashboardAccessFilter,
|
|
"Slice": ChartFilter,
|
|
"SqlaTable": DatasourceFilter,
|
|
}
|
|
|
|
by_kind: dict[str, list[int]] = {}
|
|
for api_kind, entity_id in distinct_entities:
|
|
by_kind.setdefault(api_kind, []).append(entity_id)
|
|
|
|
visible: dict[tuple[str, int], bool] = {}
|
|
for api_kind, entity_ids in by_kind.items():
|
|
if api_kind in NAME_COLUMN and api_kind not in access_filter_classes:
|
|
# The kind is in the change-records taxonomy but is missing
|
|
# an access-filter wiring — almost certainly a future-entity
|
|
# addition that updated ``TABLE_KIND_TO_API`` but forgot
|
|
# the visibility dispatch. Fail closed: the activity stream
|
|
# must not silently disclose change records for an entity
|
|
# whose access predicate is unimplemented. Warn so the gap
|
|
# surfaces in CI / staging logs before production.
|
|
logger.warning(
|
|
"activity visibility: no access filter wired for kind %r; "
|
|
"denying %d records",
|
|
api_kind,
|
|
len(entity_ids),
|
|
)
|
|
for entity_id in entity_ids:
|
|
visible[(api_kind, entity_id)] = False
|
|
continue
|
|
if api_kind not in NAME_COLUMN:
|
|
# Kind isn't in the change-records taxonomy at all — not
|
|
# something the activity-view emits today. Pass through so
|
|
# the decorator can mark it as a tombstone if appropriate.
|
|
for entity_id in entity_ids:
|
|
visible[(api_kind, entity_id)] = True
|
|
continue
|
|
model_cls = load_live_model(NAME_COLUMN[api_kind][0])
|
|
access_filter = access_filter_classes[api_kind]("id", SQLAInterface(model_cls))
|
|
|
|
# Chunk the candidate ids to stay under SQLite's bind-variable floor
|
|
# (a dashboard built from a large chart library can exceed it).
|
|
live_ids: set[int] = set()
|
|
visible_ids: set[int] = set()
|
|
for chunk in chunked_ids(entity_ids):
|
|
# Live ids — what exists at all. Used to decide tombstone vs
|
|
# not-visible: an id missing from this set is tombstoned and
|
|
# passes through (True); an id in this set but absent from the
|
|
# access-filtered set is denied (False).
|
|
live_ids.update(
|
|
row[0]
|
|
for row in db.session.query(model_cls.id)
|
|
.filter(model_cls.id.in_(chunk))
|
|
.all()
|
|
)
|
|
# Apply the SQL-side access filter to a query restricted to the
|
|
# candidate ids. Same predicate FAB uses for list endpoints, so
|
|
# results are consistent with the rest of the read surface.
|
|
visible_ids.update(
|
|
row[0]
|
|
for row in access_filter.apply(
|
|
db.session.query(model_cls.id).filter(model_cls.id.in_(chunk)),
|
|
value=None,
|
|
).all()
|
|
)
|
|
|
|
for entity_id in entity_ids:
|
|
if entity_id not in live_ids:
|
|
visible[(api_kind, entity_id)] = True
|
|
else:
|
|
visible[(api_kind, entity_id)] = entity_id in visible_ids
|
|
return visible
|