This is an automated email from the ASF dual-hosted git repository.
bbovenzi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new a462d683469 UI: Scope partitioned Dag run asset list to readable
assets (#75213)
a462d683469 is described below
commit a462d683469bb9f55646b0515ef84c41e16650fb
Author: Henry Chen <[email protected]>
AuthorDate: Wed Oct 28 02:58:34 2026 +0800
UI: Scope partitioned Dag run asset list to readable assets (#75213)
* Scope asset expressions to the assets the caller may read
`next_run_assets` scopes the asset scheduling expression it serves to the
assets the caller may read (#72864), and the dependencies graph followed
(#73008). Four endpoints still served `DagModel.asset_expression` verbatim:
the public Dag details endpoint, the UI Dag list, and both partitioned Dag
run endpoints. A caller authorized on a Dag but not on its upstream assets
could read every name and uri out of them, and the public endpoint needs no
UI to reach. Asset names and uris commonly encode bucket, table or dataset
names, which is why the rest of the asset API already scopes them.
The sibling `assets` array of the pending partitioned Dag run response
carries the same names; scoping that is a separate change with its own UI
consumers.
* UI: Scope partitioned Dag run asset list to readable assets
The pending partitioned Dag run detail response carries two asset-bearing
fields. `asset_expression` is scoped to the assets the caller may read; the
sibling `assets` array was not, so one payload could blank an asset inside
the
boolean tree while spelling its name and uri out one key over. That defeated
the redaction on this route, and the route's own asset dependency only
checks
whether the caller may read assets at all -- there is no asset id in its
path
for it to authorize against.
The counts and partition keys of a blanked asset are kept. They describe the
Dag's own schedule, which a caller who reached this endpoint can already
read,
and dropping them would leave a stuck run with no explanation at all.
This endpoint is served only to the UI: the public v2 spec regenerates
byte-identical, and no airflow-ctl model references the response.
---
.../api_fastapi/core_api/datamodels/common.py | 16 +++
.../core_api/datamodels/ui/partitioned_dag_runs.py | 12 +-
.../api_fastapi/core_api/openapi/_private_ui.yaml | 16 ++-
.../api_fastapi/core_api/routes/public/dags.py | 25 ++++-
.../airflow/api_fastapi/core_api/routes/ui/dags.py | 8 +-
.../core_api/routes/ui/partitioned_dag_runs.py | 28 ++++-
.../airflow/ui/openapi-gen/requests/schemas.gen.ts | 32 +++++-
.../airflow/ui/openapi-gen/requests/types.gen.ts | 7 +-
.../ui/src/components/AssetProgressCell.test.tsx | 63 ++++++++++-
.../ui/src/components/AssetProgressCell.tsx | 54 +++++----
.../core_api/routes/public/test_dags.py | 41 +++++++
.../api_fastapi/core_api/routes/ui/test_dags.py | 36 ++++++
.../routes/ui/test_partitioned_dag_runs.py | 122 ++++++++++++++++++++-
13 files changed, 415 insertions(+), 45 deletions(-)
diff --git a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/common.py
b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/common.py
index 5bae6c5888c..1710a661aaa 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/common.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/common.py
@@ -220,6 +220,22 @@ MaybeAssetExpression = Annotated[
]
"""``AssetExpression | None`` that degrades a legacy/unrecognized stored shape
to ``None``."""
+
+_maybe_asset_expression_adapter: TypeAdapter[Union[AssetExpression, None]] =
TypeAdapter(MaybeAssetExpression)
+
+
+def validate_asset_expression(value: Any) -> AssetExpression | None:
+ """
+ Validate a raw ``asset_expression`` tree into the typed model, the way a
response field does.
+
+ A route that rewrites the tree after its response model is built --
redacting the assets the
+ caller may not read, for instance -- must assign the result of this rather
than the raw tree.
+ FastAPI does not validate the nested models of an already-built response,
so a raw tree would
+ bypass ``MaybeAssetExpression`` and reach the client verbatim, legacy
shapes included.
+ """
+ return _maybe_asset_expression_adapter.validate_python(value)
+
+
# Common Bulk Data Models
T = TypeVar("T")
K = TypeVar("K")
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/partitioned_dag_runs.py
b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/partitioned_dag_runs.py
index 79a284b3f3f..e7126cff107 100644
---
a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/partitioned_dag_runs.py
+++
b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/partitioned_dag_runs.py
@@ -44,9 +44,15 @@ class PartitionedDagRunCollectionResponse(BaseModel):
class PartitionedDagRunAssetResponse(BaseModel):
"""Asset info within a partitioned Dag run detail."""
- asset_id: int
- asset_name: str
- asset_uri: str
+ asset_id: int | None
+ asset_name: str | None
+ asset_uri: str | None
+ hidden: bool = False
+ """True when the caller may not read this asset: ``asset_id``,
``asset_name`` and ``asset_uri``
+ are blanked and the UI must not link to the asset. The counts and
partition keys stay, so the
+ run's progress is still readable -- they describe the Dag's own schedule,
which the caller can
+ already read, rather than the asset's identity. Mirrors the redaction
+ ``redact_asset_expression`` applies to the sibling ``asset_expression``
field."""
received: bool
received_count: int
required_count: int
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
index ecf8b565089..d89efe70a34 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
+++ b/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
@@ -5531,14 +5531,24 @@ components:
PartitionedDagRunAssetResponse:
properties:
asset_id:
- type: integer
+ anyOf:
+ - type: integer
+ - type: 'null'
title: Asset Id
asset_name:
- type: string
+ anyOf:
+ - type: string
+ - type: 'null'
title: Asset Name
asset_uri:
- type: string
+ anyOf:
+ - type: string
+ - type: 'null'
title: Asset Uri
+ hidden:
+ type: boolean
+ title: Hidden
+ default: false
received:
type: boolean
title: Received
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/dags.py
b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/dags.py
index 8d3c8e08387..11dbc1792fc 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/dags.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/dags.py
@@ -25,6 +25,7 @@ from pydantic import ValidationError
from sqlalchemy import delete, func, insert, select, update
from airflow.api.common import delete_dag as delete_dag_module
+from airflow.api_fastapi.common.asset_expression import redact_asset_expression
from airflow.api_fastapi.common.dagbag import DagBagDep,
get_latest_version_of_dag
from airflow.api_fastapi.common.db.common import SessionDep,
apply_filters_to_select, paginated_select
from airflow.api_fastapi.common.db.dags import eager_load_teams,
generate_dag_with_latest_run_query
@@ -58,7 +59,11 @@ from airflow.api_fastapi.common.parameters import (
)
from airflow.api_fastapi.common.router import AirflowRouter
from airflow.api_fastapi.compat import HTTP_422_UNPROCESSABLE_CONTENT
-from airflow.api_fastapi.core_api.datamodels.common import BulkBody,
BulkResponse
+from airflow.api_fastapi.core_api.datamodels.common import (
+ BulkBody,
+ BulkResponse,
+ validate_asset_expression,
+)
from airflow.api_fastapi.core_api.datamodels.dags import (
BulkDAGBody,
DAGCollectionResponse,
@@ -71,6 +76,7 @@ from airflow.api_fastapi.core_api.openapi.exceptions import
create_openapi_http_
from airflow.api_fastapi.core_api.security import (
EditableDagsFilterDep,
GetUserDep,
+ ReadableAssetsFilterDep,
ReadableDagsFilterDep,
requires_access_dag,
requires_access_dag_bulk,
@@ -239,7 +245,11 @@ def get_dag(
dependencies=[Depends(requires_access_dag(method="GET"))],
)
def get_dag_details(
- dag_id: str, session: SessionDep, dag_bag: DagBagDep, user: GetUserDep
+ dag_id: str,
+ session: SessionDep,
+ dag_bag: DagBagDep,
+ user: GetUserDep,
+ readable_assets_filter: ReadableAssetsFilterDep,
) -> DAGDetailsResponse:
"""Get details of Dag."""
dag = get_latest_version_of_dag(dag_bag, dag_id, session)
@@ -280,7 +290,16 @@ def get_dag_details(
setattr(dag_model, "active_runs_count", active_runs_count)
setattr(dag_model, "latest_dag_version", latest_dag_version)
- return DAGDetailsResponse.model_validate(dag_model)
+ details = DAGDetailsResponse.model_validate(dag_model)
+ # The redaction lands on the response, not on `dag_model`: that is a live
ORM instance whose
+ # session commits when the request ends, so assigning the blanked leaves
back onto it would
+ # persist them.
+ details.asset_expression = validate_asset_expression(
+ redact_asset_expression(
+ dag_model.asset_expression,
readable_asset_ids=readable_assets_filter.value or set()
+ )
+ )
+ return details
@dags_router.patch(
diff --git a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/dags.py
b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/dags.py
index d011fa4c2ff..c0c747befcd 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/dags.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/dags.py
@@ -26,6 +26,7 @@ from sqlalchemy import and_, false, func, literal, select,
union_all
from sqlalchemy.orm import defaultload
from airflow.api_fastapi.auth.managers.models.resource_details import
DagAccessEntity
+from airflow.api_fastapi.common.asset_expression import redact_asset_expression
from airflow.api_fastapi.common.dagbag import DagBagDep
from airflow.api_fastapi.common.db.common import (
SessionDep,
@@ -79,6 +80,7 @@ from airflow.api_fastapi.core_api.openapi.exceptions import
create_openapi_http_
from airflow.api_fastapi.core_api.routes.public.hitl import HITLDetailView
from airflow.api_fastapi.core_api.security import (
GetUserDep,
+ ReadableAssetsFilterDep,
ReadableDagsFilterDep,
requires_access_dag,
)
@@ -155,6 +157,7 @@ def get_dags(
],
has_pending_actions: QueryPendingActionsFilter,
readable_dags_filter: ReadableDagsFilterDep,
+ readable_assets_filter: ReadableAssetsFilterDep,
session: SessionDep,
dag_bag: DagBagDep,
user: GetUserDep,
@@ -311,6 +314,7 @@ def get_dags(
# Build the dict dynamically from DAGResponse.model_fields so that new
fields
# added to DAGResponse are picked up automatically without code changes
here.
dag_runs_by_dag_id: dict[str, DAGWithLatestDagRunsResponse] = {}
+ readable_asset_ids = readable_assets_filter.value or set()
for dag in dags:
dag_data = {
DAG_ALIAS_MAPPING.get(field_name, field_name): getattr(
@@ -320,7 +324,9 @@ def get_dags(
}
dag_data.update(
{
- "asset_expression": dag.asset_expression,
+ "asset_expression": redact_asset_expression(
+ dag.asset_expression, readable_asset_ids=readable_asset_ids
+ ),
"latest_dag_runs": [],
"has_unfinished_runs":
has_unfinished_runs_by_dag_id[dag.dag_id],
"pending_actions": pending_actions_by_dag_id[dag.dag_id],
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/partitioned_dag_runs.py
b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/partitioned_dag_runs.py
index f87e4cd12b2..899f6aca081 100644
---
a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/partitioned_dag_runs.py
+++
b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/partitioned_dag_runs.py
@@ -22,6 +22,7 @@ import structlog
from fastapi import Depends, HTTPException, status
from sqlalchemy import and_, select
+from airflow.api_fastapi.common.asset_expression import redact_asset_expression
from airflow.api_fastapi.common.db.common import SessionDep,
apply_filters_to_select, paginated_select
from airflow.api_fastapi.common.parameters import (
QueryLimit,
@@ -42,6 +43,7 @@ from
airflow.api_fastapi.core_api.datamodels.ui.partitioned_dag_runs import (
)
from airflow.api_fastapi.core_api.openapi.exceptions import
create_openapi_http_exception_doc
from airflow.api_fastapi.core_api.security import (
+ ReadableAssetsFilterDep,
ReadableDagsFilterDep,
requires_access_asset,
requires_access_dag,
@@ -251,6 +253,7 @@ def get_partitioned_dag_runs(
limit: QueryLimit,
offset: QueryOffset,
readable_dags_filter: ReadableDagsFilterDep,
+ readable_assets_filter: ReadableAssetsFilterDep,
dag_id: QueryPartitionedDagRunDagIdFilter,
has_created_dag_run_id: QueryPartitionedDagRunHasCreatedDagRunIdFilter,
) -> PartitionedDagRunCollectionResponse:
@@ -351,7 +354,11 @@ def get_partitioned_dag_runs(
asset_expressions: dict[str, dict | None] | None = None
if dag_id.value is None:
- asset_expressions = {dm.dag_id: dm.asset_expression for dm in
dag_models.values()}
+ readable_asset_ids = readable_assets_filter.value or set()
+ asset_expressions = {
+ dm.dag_id: redact_asset_expression(dm.asset_expression,
readable_asset_ids=readable_asset_ids)
+ for dm in dag_models.values()
+ }
model_data: dict[str, Any] = {
"partitioned_dag_runs": results,
@@ -370,6 +377,7 @@ def get_pending_partitioned_dag_run(
dag_id: str,
partition_key: str,
session: SessionDep,
+ readable_assets_filter: ReadableAssetsFilterDep,
partitioned_dag_run_id: int | None = None,
) -> PartitionedDagRunDetailResponse:
"""Return full details for pending PartitionedDagRun."""
@@ -437,8 +445,13 @@ def get_pending_partitioned_dag_run(
has_rollup_mappers = dag_model is not None and dag_model.has_rollup_mappers
rollup_timetable = load_partitioned_timetable(dag_id, session) if
has_rollup_mappers else None
+ readable_asset_ids = readable_assets_filter.value or set()
+
assets = []
for asset_row in asset_rows:
+ # The row itself is still needed in full below: the rollup mapper is
keyed by the asset's
+ # real name and uri, and the received-key lookup by its real id. Only
the response is blanked.
+ is_hidden = asset_row.id not in readable_asset_ids
received_keys = sorted(received_keys_by_asset.get(asset_row.id, set()))
required_keys: list[str] = [partition_key]
is_rollup = (
@@ -480,9 +493,10 @@ def get_pending_partitioned_dag_run(
received_count = 1 if received_keys else 0
assets.append(
PartitionedDagRunAssetResponse(
- asset_id=asset_row.id,
- asset_name=asset_row.name,
- asset_uri=asset_row.uri,
+ asset_id=None if is_hidden else asset_row.id,
+ asset_name=None if is_hidden else asset_row.name,
+ asset_uri=None if is_hidden else asset_row.uri,
+ hidden=is_hidden,
received=received_count >= required_count and required_count >
0,
received_count=received_count,
required_count=required_count,
@@ -496,7 +510,11 @@ def get_pending_partitioned_dag_run(
total_received = sum(a.received_count for a in assets)
total_required = sum(a.required_count for a in assets)
- asset_expression = dag_model.asset_expression if dag_model is not None
else None
+ asset_expression = (
+ redact_asset_expression(dag_model.asset_expression,
readable_asset_ids=readable_asset_ids)
+ if dag_model is not None
+ else None
+ )
model_data: dict[str, Any] = {
"id": partitioned_dag_run.id,
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
index c3d8c0f2da7..8c151dcf36f 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
@@ -12954,17 +12954,43 @@ export const $OklchColor = {
export const $PartitionedDagRunAssetResponse = {
properties: {
asset_id: {
- type: 'integer',
+ anyOf: [
+ {
+ type: 'integer'
+ },
+ {
+ type: 'null'
+ }
+ ],
title: 'Asset Id'
},
asset_name: {
- type: 'string',
+ anyOf: [
+ {
+ type: 'string'
+ },
+ {
+ type: 'null'
+ }
+ ],
title: 'Asset Name'
},
asset_uri: {
- type: 'string',
+ anyOf: [
+ {
+ type: 'string'
+ },
+ {
+ type: 'null'
+ }
+ ],
title: 'Asset Uri'
},
+ hidden: {
+ type: 'boolean',
+ title: 'Hidden',
+ default: false
+ },
received: {
type: 'boolean',
title: 'Received'
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
index 86a1680d2c5..c34a9a1dfee 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
@@ -3317,9 +3317,10 @@ export type OklchColor = string;
* Asset info within a partitioned Dag run detail.
*/
export type PartitionedDagRunAssetResponse = {
- asset_id: number;
- asset_name: string;
- asset_uri: string;
+ asset_id: number | null;
+ asset_name: string | null;
+ asset_uri: string | null;
+ hidden?: boolean;
received: boolean;
received_count: number;
required_count: number;
diff --git
a/airflow-core/src/airflow/ui/src/components/AssetProgressCell.test.tsx
b/airflow-core/src/airflow/ui/src/components/AssetProgressCell.test.tsx
index 291f3198a9b..f8b8cc8d758 100644
--- a/airflow-core/src/airflow/ui/src/components/AssetProgressCell.test.tsx
+++ b/airflow-core/src/airflow/ui/src/components/AssetProgressCell.test.tsx
@@ -16,11 +16,13 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { render, waitFor } from "@testing-library/react";
+import "@testing-library/jest-dom/vitest";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { expect, it, vi } from "vitest";
import { PartitionedDagRunService } from "openapi/requests/services.gen";
+import i18n from "src/i18n/config";
import { Wrapper } from "src/utils/Wrapper";
import { AssetProgressCell } from "./AssetProgressCell";
@@ -68,3 +70,62 @@ it("fetches separate details for pending runs sharing a
partition key", async ()
});
});
});
+
+it("labels a hidden asset instead of linking to it, leaving readable assets
linked", async () => {
+ // The shape the pending-detail endpoint serves for an asset the caller may
not read.
+ vi.spyOn(PartitionedDagRunService,
"getPendingPartitionedDagRun").mockResolvedValue({
+ assets: [
+ {
+ asset_id: 1,
+ asset_name: "sales_daily",
+ asset_uri: "s3://bucket/sales",
+ hidden: false,
+ is_rollup: true,
+ received: false,
+ received_count: 0,
+ received_keys: [],
+ required_count: 1,
+ required_keys: ["2024-07-01"],
+ },
+ {
+ asset_id: null,
+ asset_name: null,
+ asset_uri: null,
+ hidden: true,
+ is_rollup: true,
+ received: false,
+ received_count: 0,
+ received_keys: [],
+ required_count: 1,
+ required_keys: ["2024-07-01"],
+ },
+ ],
+ dag_id: "consumer",
+ id: 1,
+ partition_key: "2024-07-01",
+ total_received: 0,
+ total_required: 2,
+ });
+
+ render(
+ <Wrapper>
+ <AssetProgressCell
+ dagId="consumer"
+ partitionedDagRunId={1}
+ partitionKey="2024-07-01"
+ totalReceived={0}
+ totalRequired={2}
+ />
+ </Wrapper>,
+ );
+
+ const trigger = screen.getByRole("button");
+
+ // The trigger is disabled while the detail query is in flight, which
swallows the click.
+ await waitFor(() => expect(trigger).not.toBeDisabled());
+ fireEvent.click(trigger);
+
+ await waitFor(() =>
expect(screen.getByText(i18n.t("common:expression.hiddenAsset"))).toBeInTheDocument());
+ expect(screen.getByRole("link", { name: "sales_daily"
})).toHaveAttribute("href", "/assets/1");
+ expect(screen.getAllByRole("link")).toHaveLength(1);
+});
diff --git a/airflow-core/src/airflow/ui/src/components/AssetProgressCell.tsx
b/airflow-core/src/airflow/ui/src/components/AssetProgressCell.tsx
index 799499b6bcf..fba23ea9f26 100644
--- a/airflow-core/src/airflow/ui/src/components/AssetProgressCell.tsx
+++ b/airflow-core/src/airflow/ui/src/components/AssetProgressCell.tsx
@@ -16,9 +16,9 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { Button, HStack, VStack } from "@chakra-ui/react";
+import { Button, HStack, Text, VStack } from "@chakra-ui/react";
import { useTranslation } from "react-i18next";
-import { FiDatabase } from "react-icons/fi";
+import { FiDatabase, FiLock } from "react-icons/fi";
import { usePartitionedDagRunServiceGetPendingPartitionedDagRun } from
"openapi/queries";
import type { NextRunAssetEventResponse, PartitionedDagRunAssetResponse } from
"openapi/requests/types.gen";
@@ -45,7 +45,7 @@ export const AssetProgressCell = ({
totalReceived,
totalRequired,
}: Props) => {
- const { i18n } = useTranslation();
+ const { i18n, t: translate } = useTranslation();
const { data, isLoading } =
usePartitionedDagRunServiceGetPendingPartitionedDagRun({
dagId,
partitionedDagRunId,
@@ -57,17 +57,24 @@ export const AssetProgressCell = ({
const hasRollup = assets.some((ak) => ak.is_rollup);
- const events: Array<NextRunAssetEventResponse> = assets
- .filter((ak: PartitionedDagRunAssetResponse) => ak.received_count > 0)
- .map((ak: PartitionedDagRunAssetResponse) => ({
- id: ak.asset_id,
- is_rollup: ak.is_rollup,
- last_update: ak.received ? "received" : null,
- name: ak.asset_name,
- received_count: ak.received_count,
- required_count: ak.required_count,
- uri: ak.asset_uri,
- }));
+ // An asset the caller may not read arrives with its id, name and uri
blanked, so it has nothing
+ // to match an expression leaf on -- and the leaf it would have matched is
redacted to `id: null`
+ // too. Dropping it here keeps the event list to the assets that can
actually line up with one.
+ const events: Array<NextRunAssetEventResponse> = assets.flatMap((ak:
PartitionedDagRunAssetResponse) =>
+ ak.received_count > 0 && ak.asset_id !== null && ak.asset_name !== null &&
ak.asset_uri !== null
+ ? [
+ {
+ id: ak.asset_id,
+ is_rollup: ak.is_rollup,
+ last_update: ak.received ? "received" : null,
+ name: ak.asset_name,
+ received_count: ak.received_count,
+ required_count: ak.required_count,
+ uri: ak.asset_uri,
+ },
+ ]
+ : [],
+ );
return (
// eslint-disable-next-line jsx-a11y/no-autofocus
@@ -85,11 +92,20 @@ export const AssetProgressCell = ({
<HStack align="start" gap={4} maxH="300px" overflowX="auto"
overflowY="auto">
{assets
.filter((ak) => ak.is_rollup)
- .map((ak) => (
- <VStack align="start" gap={1} key={ak.asset_id}>
- <RouterLink fontSize="xs" fontWeight="semibold"
to={`/assets/${ak.asset_id}`}>
- {ak.asset_name}
- </RouterLink>
+ .map((ak, index) => (
+ // A hidden asset has no id to key on; the rows come back
ordered by the asset's
+ // uri, so the position within one response is stable.
+ <VStack align="start" gap={1} key={ak.asset_id ??
`hidden-${index}`}>
+ {ak.hidden ? (
+ <HStack color="fg.muted" fontSize="xs"
fontWeight="semibold" gap={1}>
+ <FiLock />
+ <Text>{translate("expression.hiddenAsset")}</Text>
+ </HStack>
+ ) : (
+ <RouterLink fontSize="xs" fontWeight="semibold"
to={`/assets/${ak.asset_id}`}>
+ {ak.asset_name}
+ </RouterLink>
+ )}
<RollupKeyChecklist receivedKeys={ak.received_keys}
requiredKeys={ak.required_keys} />
</VStack>
))}
diff --git
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dags.py
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dags.py
index 5c184bfb784..186c75e42cc 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dags.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dags.py
@@ -1776,6 +1776,47 @@ class TestDagDetails(TestDagEndpoint):
assert isinstance(body["is_favorite"], bool)
assert body["is_favorite"] is False
+ @mock.patch(
+
"airflow.api_fastapi.auth.managers.simple.simple_auth_manager.SimpleAuthManager.get_authorized_assets",
+ autospec=True,
+ )
+ def test_dag_details_hides_assets_the_caller_may_not_read(
+ self, mock_get_authorized_assets, session, test_client
+ ):
+ dag_model = session.get(DagModel, DAG2_ID)
+ dag_model.asset_expression = {
+ "all": [
+ {"asset": {"uri": "s3://bucket/visible", "name": "visible",
"group": "asset", "id": 1}},
+ {"asset": {"uri": "s3://bucket/secret", "name": "secret",
"group": "asset", "id": 2}},
+ ]
+ }
+ session.commit()
+ mock_get_authorized_assets.return_value = {1}
+
+ response = test_client.get(f"/dags/{DAG2_ID}/details")
+
+ assert response.status_code == 200
+ assert response.json()["asset_expression"] == {
+ "all": [
+ {
+ "asset": {
+ "uri": "s3://bucket/visible",
+ "name": "visible",
+ "group": "asset",
+ "id": 1,
+ "hidden": False,
+ }
+ },
+ {"asset": {"uri": None, "name": None, "group": "asset", "id":
None, "hidden": True}},
+ ]
+ }
+ assert "s3://bucket/secret" not in response.text
+
+ # Only the response is redacted: writing the blanked leaves back
through the request
+ # session would overwrite the stored expression when that session
commits.
+ session.expire_all()
+ assert session.get(DagModel,
DAG2_ID).asset_expression["all"][1]["asset"]["name"] == "secret"
+
def test_dag_details_serves_legacy_asset_expression_as_null(self, session,
test_client):
"""A pre-3.0 dataset-format ``asset_expression`` that the typed model
cannot describe is
served as ``null`` instead of 500ing the endpoint (handled by
``MaybeAssetExpression``)."""
diff --git
a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_dags.py
b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_dags.py
index ce504955e7e..cc33d43f540 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_dags.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_dags.py
@@ -559,6 +559,42 @@ class TestGetDagRuns(TestPublicDagEndpoint):
f"the attribute does not exist on the model"
)
+ @mock.patch(
+
"airflow.api_fastapi.auth.managers.simple.simple_auth_manager.SimpleAuthManager.get_authorized_assets",
+ autospec=True,
+ )
+ def test_hides_assets_the_caller_may_not_read(self,
mock_get_authorized_assets, test_client, session):
+ dag_model = session.get(DagModel, DAG1_ID)
+ dag_model.asset_expression = {
+ "any": [
+ {"asset": {"uri": "s3://bucket/visible", "name": "visible",
"group": "asset", "id": 1}},
+ {"asset": {"uri": "s3://bucket/secret", "name": "secret",
"group": "asset", "id": 2}},
+ ]
+ }
+ session.commit()
+ mock_get_authorized_assets.return_value = {1}
+
+ response = test_client.get("/dags", params={"dag_ids": [DAG1_ID]})
+
+ assert response.status_code == 200
+ assert response.json()["dags"][0]["asset_expression"] == {
+ "any": [
+ {
+ "asset": {
+ "uri": "s3://bucket/visible",
+ "name": "visible",
+ "group": "asset",
+ "id": 1,
+ "hidden": False,
+ }
+ },
+ # The route is served with ``response_model_exclude_none``, so
the blanked fields
+ # drop out of the payload entirely.
+ {"asset": {"group": "asset", "hidden": True}},
+ ]
+ }
+ assert "s3://bucket/secret" not in response.text
+
def test_get_dags_no_n_plus_one_queries(self, session, test_client):
"""Test that fetching DAGs with tags doesn't trigger n+1 queries."""
num_dags = 5
diff --git
a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_partitioned_dag_runs.py
b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_partitioned_dag_runs.py
index 1fe5df0b9a3..4749b68e5fc 100644
---
a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_partitioned_dag_runs.py
+++
b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_partitioned_dag_runs.py
@@ -59,6 +59,51 @@ class TestGetPartitionedDagRuns:
response =
unauthorized_test_client.get("/partitioned_dag_runs?dag_id=any")
assert response.status_code == 403
+ @mock.patch(
+
"airflow.api_fastapi.auth.managers.simple.simple_auth_manager.SimpleAuthManager.get_authorized_assets",
+ autospec=True,
+ )
+ def test_hides_assets_the_caller_may_not_read(
+ self, mock_get_authorized_assets, test_client, dag_maker, session
+ ):
+ visible = Asset(uri="s3://bucket/visible", name="visible")
+ secret = Asset(uri="s3://bucket/secret", name="secret")
+ with dag_maker(
+ dag_id="redacted_dag",
+ schedule=PartitionedAssetTimetable(assets=visible & secret),
+ serialized=True,
+ ):
+ EmptyOperator(task_id="t")
+ dag_maker.create_dagrun()
+ dag_maker.sync_dagbag_to_db()
+
+ session.add(
+ AssetPartitionDagRun(
+ target_dag_id="redacted_dag", partition_key="2024-06-01",
created_dag_run_id=None
+ )
+ )
+ session.commit()
+ visible_id =
session.scalar(select(AssetModel.id).where(AssetModel.name == "visible"))
+ mock_get_authorized_assets.return_value = {visible_id}
+
+ resp = test_client.get("/partitioned_dag_runs")
+
+ assert resp.status_code == 200
+ assert resp.json()["asset_expressions"]["redacted_dag"] == {
+ "all": [
+ {
+ "asset": {
+ "uri": "s3://bucket/visible",
+ "name": "visible",
+ "group": "asset",
+ "id": visible_id,
+ "hidden": False,
+ }
+ },
+ {"asset": {"uri": None, "name": None, "group": "asset", "id":
None, "hidden": True}},
+ ]
+ }
+
def test_should_response_404(self, test_client):
assert
test_client.get("/partitioned_dag_runs?dag_id=no_such_dag").status_code == 404
@@ -68,7 +113,7 @@ class TestGetPartitionedDagRuns:
dag_maker.create_dagrun()
dag_maker.sync_dagbag_to_db()
- with assert_queries_count(4):
+ with assert_queries_count(5):
resp =
test_client.get("/partitioned_dag_runs?dag_id=normal&has_created_dag_run_id=false")
assert resp.status_code == 200
assert resp.json() == {"partitioned_dag_runs": [], "total": 0,
"asset_expressions": None}
@@ -155,17 +200,18 @@ class TestGetPartitionedDagRuns:
)
session.commit()
- # Six batch queries, each independent of len(rows) / num_assets:
+ # Seven batch queries, each independent of len(rows) / num_assets:
# 1. SELECT COUNT of matching AssetPartitionDagRun rows
# 2. outer SELECT of paginated AssetPartitionDagRun rows
# 3. SELECT DagModel WHERE dag_id IN (unique_dag_ids)
# 4. _fetch_active_assets_per_dag — joined SELECT on AssetModel +
DagScheduleAssetReference
# 5. SELECT PartitionedAssetKeyLog WHERE asset_partition_dag_run_id IN
(apdr_ids)
# 6. SELECT timetable for asset_expressions (cached per response)
+ # 7. SELECT AssetModel.id for the assets the caller may read
# The count does not scale with num_assets or len(rows), so the bump
- # from main's baseline of 3 (3 → 4 → 5 → 6 across this branch) is
constant
+ # from main's baseline of 3 (3 → 4 → 5 → 6 → 7 across this branch) is
constant
# per request, not per row.
- with assert_queries_count(6):
+ with assert_queries_count(7):
resp = test_client.get(
f"/partitioned_dag_runs?dag_id=list_dag"
f"&has_created_dag_run_id={str(has_created_dag_run_id).lower()}"
@@ -740,6 +786,74 @@ class TestGetPendingPartitionedDagRun:
received_uris = {a["asset_uri"] for a in body["assets"] if
a["received"]}
assert received_uris == set(uris[:received_count])
+ @mock.patch(
+
"airflow.api_fastapi.auth.managers.simple.simple_auth_manager.SimpleAuthManager.get_authorized_assets",
+ autospec=True,
+ )
+ def test_hides_assets_the_caller_may_not_read(
+ self, mock_get_authorized_assets, test_client, dag_maker, session
+ ):
+ visible = Asset(uri="s3://bucket/visible", name="visible")
+ secret = Asset(uri="s3://bucket/secret", name="secret")
+ with dag_maker(
+ dag_id="redacted_detail_dag",
+ schedule=PartitionedAssetTimetable(assets=visible & secret),
+ serialized=True,
+ ):
+ EmptyOperator(task_id="t")
+ dag_maker.create_dagrun()
+ dag_maker.sync_dagbag_to_db()
+
+ session.add(
+ AssetPartitionDagRun(
+ target_dag_id="redacted_detail_dag",
partition_key="2024-07-01", created_dag_run_id=None
+ )
+ )
+ session.commit()
+ visible_id =
session.scalar(select(AssetModel.id).where(AssetModel.name == "visible"))
+ mock_get_authorized_assets.return_value = {visible_id}
+
+ resp =
test_client.get("/pending_partitioned_dag_run/redacted_detail_dag?partition_key=2024-07-01")
+
+ assert resp.status_code == 200
+ body = resp.json()
+ # Rows come back ordered by the asset's uri, so "secret" precedes
"visible".
+ assert [
+ {k: a[k] for k in ("asset_id", "asset_name", "asset_uri",
"hidden", "required_count")}
+ for a in body["assets"]
+ ] == [
+ {
+ "asset_id": None,
+ "asset_name": None,
+ "asset_uri": None,
+ "hidden": True,
+ "required_count": 1,
+ },
+ {
+ "asset_id": visible_id,
+ "asset_name": "visible",
+ "asset_uri": "s3://bucket/visible",
+ "hidden": False,
+ "required_count": 1,
+ },
+ ]
+ # Progress stays readable: a blanked asset still counts toward the
run's totals.
+ assert body["total_required"] == 2
+ assert body["asset_expression"] == {
+ "all": [
+ {
+ "asset": {
+ "uri": "s3://bucket/visible",
+ "name": "visible",
+ "group": "asset",
+ "id": visible_id,
+ "hidden": False,
+ }
+ },
+ {"asset": {"uri": None, "name": None, "group": "asset", "id":
None, "hidden": True}},
+ ]
+ }
+
def test_is_rollup_false_for_non_rollup_asset(self, test_client,
dag_maker, session):
"""is_rollup is False for every asset when the schedule uses identity
(non-rollup) mappers."""
asset_defs = [