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 9dcd6a0a2ee Hide unreadable assets in the asset expression served by
next_run_assets (#72864)
9dcd6a0a2ee is described below
commit 9dcd6a0a2eeca35168a5ba69363dbb7253c5701d
Author: Henry Chen <[email protected]>
AuthorDate: Sat Sep 19 02:29:31 2026 +0800
Hide unreadable assets in the asset expression served by next_run_assets
(#72864)
---
.../airflow/api_fastapi/common/asset_expression.py | 62 ++++++++++++++++++
.../api_fastapi/core_api/datamodels/common.py | 9 ++-
.../api_fastapi/core_api/openapi/_private_ui.yaml | 23 ++++++-
.../core_api/openapi/v2-rest-api-generated.yaml | 23 ++++++-
.../api_fastapi/core_api/routes/ui/assets.py | 12 +++-
.../airflow/ui/openapi-gen/requests/schemas.gen.ts | 29 ++++++++-
.../airflow/ui/openapi-gen/requests/types.gen.ts | 9 ++-
.../api_fastapi/common/test_asset_expression.py | 73 ++++++++++++++++++++++
.../api_fastapi/core_api/datamodels/test_common.py | 14 ++++-
.../api_fastapi/core_api/routes/ui/test_assets.py | 48 +++++++++++++-
.../src/airflowctl/api/datamodels/generated.py | 9 ++-
11 files changed, 290 insertions(+), 21 deletions(-)
diff --git a/airflow-core/src/airflow/api_fastapi/common/asset_expression.py
b/airflow-core/src/airflow/api_fastapi/common/asset_expression.py
new file mode 100644
index 00000000000..b926677a758
--- /dev/null
+++ b/airflow-core/src/airflow/api_fastapi/common/asset_expression.py
@@ -0,0 +1,62 @@
+#
+# 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.
+"""Authorization-aware handling of the ``DagModel.asset_expression`` tree
served by the API."""
+
+from __future__ import annotations
+
+from collections.abc import Collection
+from typing import Any
+
+
+def redact_asset_expression(
+ expression: dict[str, Any] | None, *, readable_asset_ids: Collection[int]
+) -> dict[str, Any] | None:
+ """
+ Return a copy of an asset scheduling expression with the assets the caller
may not read hidden.
+
+ ``DagModel.asset_expression`` names every upstream asset of a Dag, so any
endpoint that serves it
+ must scope it to the caller the same way the asset list endpoints do. An
``asset`` leaf whose id is
+ not in ``readable_asset_ids`` keeps its place in the boolean tree (so the
shape of the schedule is
+ still honest) but has its identifying fields blanked and ``hidden`` set. A
leaf without an id, such
+ as a row not yet re-enriched by the Dag processor, cannot be authorized
and is hidden as well.
+
+ ``alias`` and ``asset_ref`` leaves are returned unchanged: the auth
manager exposes no batch
+ authorization for aliases, and references are unresolved names by design.
+
+ The input is never mutated: the value lives on an ORM instance whose
session commits on exit.
+ """
+ if expression is None:
+ return None
+ return _redact_node(expression, readable_asset_ids)
+
+
+def _redact_node(node: Any, readable_asset_ids: Collection[int]) -> Any:
+ if not isinstance(node, dict):
+ # Legacy pre-3.0 shapes hold bare strings; ``MaybeAssetExpression``
drops those later.
+ return node
+ if "asset" in node:
+ asset = node["asset"]
+ if not isinstance(asset, dict):
+ return node
+ if asset.get("id") in readable_asset_ids:
+ return {"asset": dict(asset)}
+ return {"asset": {"uri": None, "name": None, "group":
asset.get("group"), "id": None, "hidden": True}}
+ for key in ("all", "any"):
+ if key in node and isinstance(node[key], list):
+ return {key: [_redact_node(child, readable_asset_ids) for child in
node[key]]}
+ return node
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 75fd131d2a2..3caad7b34a6 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
@@ -50,12 +50,17 @@ class AssetExpressionAssetInfo(BaseModel):
persisted; ``BaseAsset.as_expression()`` itself only emits
``uri``/``name``/``group``. It is left
optional so a row persisted before id-enrichment (or migrated from the
pre-3.0 dataset format)
degrades gracefully instead of failing response validation.
+
+ A leaf the caller is not authorized to read is served with ``hidden`` set
and ``uri``, ``name``
+ and ``id`` blanked (see ``airflow.api_fastapi.common.asset_expression``),
so the shape of the
+ schedule stays visible without revealing which asset it waits on.
"""
- uri: str
- name: str
+ uri: str | None
+ name: str | None
group: str
id: int | None = None
+ hidden: bool = False
class AssetExpressionAliasInfo(BaseModel):
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 732c9202acf..7af3e1262f4 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
@@ -2450,10 +2450,14 @@ components:
AssetExpressionAssetInfo:
properties:
uri:
- type: string
+ anyOf:
+ - type: string
+ - type: 'null'
title: Uri
name:
- type: string
+ anyOf:
+ - type: string
+ - type: 'null'
title: Name
group:
type: string
@@ -2463,6 +2467,10 @@ components:
- type: integer
- type: 'null'
title: Id
+ hidden:
+ type: boolean
+ title: Hidden
+ default: false
type: object
required:
- uri
@@ -2481,7 +2489,16 @@ components:
optional so a row persisted before id-enrichment (or migrated from the
pre-3.0
dataset format)
- degrades gracefully instead of failing response validation.'
+ degrades gracefully instead of failing response validation.
+
+
+ A leaf the caller is not authorized to read is served with ``hidden``
set
+ and ``uri``, ``name``
+
+ and ``id`` blanked (see
``airflow.api_fastapi.common.asset_expression``),
+ so the shape of the
+
+ schedule stays visible without revealing which asset it waits on.'
AssetExpressionRef:
properties:
asset_ref:
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
index 6b6bae9b1dc..a9e9be01f4a 100644
---
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
+++
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
@@ -11366,10 +11366,14 @@ components:
AssetExpressionAssetInfo:
properties:
uri:
- type: string
+ anyOf:
+ - type: string
+ - type: 'null'
title: Uri
name:
- type: string
+ anyOf:
+ - type: string
+ - type: 'null'
title: Name
group:
type: string
@@ -11379,6 +11383,10 @@ components:
- type: integer
- type: 'null'
title: Id
+ hidden:
+ type: boolean
+ title: Hidden
+ default: false
type: object
required:
- uri
@@ -11397,7 +11405,16 @@ components:
optional so a row persisted before id-enrichment (or migrated from the
pre-3.0
dataset format)
- degrades gracefully instead of failing response validation.'
+ degrades gracefully instead of failing response validation.
+
+
+ A leaf the caller is not authorized to read is served with ``hidden``
set
+ and ``uri``, ``name``
+
+ and ``id`` blanked (see
``airflow.api_fastapi.common.asset_expression``),
+ so the shape of the
+
+ schedule stays visible without revealing which asset it waits on.'
AssetExpressionRef:
properties:
asset_ref:
diff --git a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/assets.py
b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/assets.py
index dd5feb55783..9fb73471300 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/assets.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/assets.py
@@ -22,6 +22,7 @@ import structlog
from fastapi import Depends, HTTPException, status
from sqlalchemy import ColumnElement, and_, case, exists, func, select, true
+from airflow.api_fastapi.common.asset_expression import redact_asset_expression
from airflow.api_fastapi.common.db.assets import
generate_assets_with_last_event_query
from airflow.api_fastapi.common.db.common import SessionDep, paginated_select
from airflow.api_fastapi.common.parameters import (
@@ -149,12 +150,17 @@ def get_assets(
)
def next_run_assets(
dag_id: str,
+ readable_assets_filter: ReadableAssetsFilterDep,
session: SessionDep,
) -> NextRunAssetsResponse:
dag_model = DagModel.get_dagmodel(dag_id, session=session)
if dag_model is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, f"Dag with id {dag_id}
was not found")
+ asset_expression = redact_asset_expression(
+ dag_model.asset_expression,
readable_asset_ids=readable_assets_filter.value or set()
+ )
+
latest_run = dag_model.get_last_dagrun(session=session)
event_filter = (
AssetEvent.timestamp >= latest_run.logical_date if latest_run and
latest_run.logical_date else true()
@@ -241,7 +247,7 @@ def next_run_assets(
)
for row in raw_rows
]
- model_data: dict[str, Any] = {"asset_expression":
dag_model.asset_expression, "events": events}
+ model_data: dict[str, Any] = {"asset_expression": asset_expression,
"events": events}
return NextRunAssetsResponse.model_validate(model_data)
# Partitioned Dags: enrich with per-asset received/required counts and
rollup flag.
@@ -276,7 +282,7 @@ def next_run_assets(
for row in raw_rows
]
model_data = {
- "asset_expression": dag_model.asset_expression,
+ "asset_expression": asset_expression,
"events": events,
"pending_partition_count": pending_partition_count,
}
@@ -350,7 +356,7 @@ def next_run_assets(
)
model_data = {
- "asset_expression": dag_model.asset_expression,
+ "asset_expression": asset_expression,
"events": events,
"pending_partition_count": pending_partition_count,
}
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 4771e7de12e..566cd1dba51 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
@@ -423,11 +423,25 @@ export const $AssetExpressionAsset = {
export const $AssetExpressionAssetInfo = {
properties: {
uri: {
- type: 'string',
+ anyOf: [
+ {
+ type: 'string'
+ },
+ {
+ type: 'null'
+ }
+ ],
title: 'Uri'
},
name: {
- type: 'string',
+ anyOf: [
+ {
+ type: 'string'
+ },
+ {
+ type: 'null'
+ }
+ ],
title: 'Name'
},
group: {
@@ -444,6 +458,11 @@ export const $AssetExpressionAssetInfo = {
}
],
title: 'Id'
+ },
+ hidden: {
+ type: 'boolean',
+ title: 'Hidden',
+ default: false
}
},
type: 'object',
@@ -454,7 +473,11 @@ export const $AssetExpressionAssetInfo = {
\`\`id\`\` is injected by
\`\`DagModelOperation.update_dag_asset_expression\`\` when the expression is
persisted; \`\`BaseAsset.as_expression()\`\` itself only emits
\`\`uri\`\`/\`\`name\`\`/\`\`group\`\`. It is left
optional so a row persisted before id-enrichment (or migrated from the pre-3.0
dataset format)
-degrades gracefully instead of failing response validation.`
+degrades gracefully instead of failing response validation.
+
+A leaf the caller is not authorized to read is served with \`\`hidden\`\` set
and \`\`uri\`\`, \`\`name\`\`
+and \`\`id\`\` blanked (see
\`\`airflow.api_fastapi.common.asset_expression\`\`), so the shape of the
+schedule stays visible without revealing which asset it waits on.`
} as const;
export const $AssetExpressionRef = {
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 dc08fe4d80e..91ddaf68f7d 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
@@ -126,12 +126,17 @@ export type AssetExpressionAsset = {
* persisted; ``BaseAsset.as_expression()`` itself only emits
``uri``/``name``/``group``. It is left
* optional so a row persisted before id-enrichment (or migrated from the
pre-3.0 dataset format)
* degrades gracefully instead of failing response validation.
+ *
+ * A leaf the caller is not authorized to read is served with ``hidden`` set
and ``uri``, ``name``
+ * and ``id`` blanked (see ``airflow.api_fastapi.common.asset_expression``),
so the shape of the
+ * schedule stays visible without revealing which asset it waits on.
*/
export type AssetExpressionAssetInfo = {
- uri: string;
- name: string;
+ uri: string | null;
+ name: string | null;
group: string;
id?: number | null;
+ hidden?: boolean;
};
/**
diff --git
a/airflow-core/tests/unit/api_fastapi/common/test_asset_expression.py
b/airflow-core/tests/unit/api_fastapi/common/test_asset_expression.py
new file mode 100644
index 00000000000..8f7f7d150e7
--- /dev/null
+++ b/airflow-core/tests/unit/api_fastapi/common/test_asset_expression.py
@@ -0,0 +1,73 @@
+#
+# 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 __future__ import annotations
+
+import copy
+
+import pytest
+
+from airflow.api_fastapi.common.asset_expression import redact_asset_expression
+
+_VISIBLE = {"asset": {"uri": "s3://bucket/visible", "name": "visible",
"group": "asset", "id": 1}}
+_HIDDEN = {"asset": {"uri": "s3://bucket/hidden", "name": "hidden", "group":
"asset", "id": 2}}
+_REDACTED = {"asset": {"uri": None, "name": None, "group": "asset", "id":
None, "hidden": True}}
+_ALIAS = {"alias": {"name": "my_alias", "group": "asset"}}
+_REF = {"asset_ref": {"name": "by_name"}}
+
+
+def test_none_passes_through():
+ assert redact_asset_expression(None, readable_asset_ids={1}) is None
+
+
[email protected](
+ ("expression", "expected"),
+ [
+ pytest.param(_VISIBLE, _VISIBLE, id="readable_leaf_unchanged"),
+ pytest.param(_HIDDEN, _REDACTED, id="unreadable_leaf_redacted"),
+ pytest.param(
+ {"asset": {"uri": "s3://b", "name": "n", "group": "asset"}},
+ _REDACTED,
+ id="leaf_without_id_fails_closed",
+ ),
+ pytest.param(_ALIAS, _ALIAS, id="alias_untouched"),
+ pytest.param(_REF, _REF, id="asset_ref_untouched"),
+ pytest.param(
+ {"all": [_VISIBLE, {"any": [_HIDDEN, _ALIAS]}, _HIDDEN]},
+ {"all": [_VISIBLE, {"any": [_REDACTED, _ALIAS]}, _REDACTED]},
+ id="nested_keeps_shape",
+ ),
+ pytest.param(
+ {"any": ["s3://legacy-a", "s3://legacy-b"]},
+ {"any": ["s3://legacy-a", "s3://legacy-b"]},
+ id="legacy_string_leaves_left_for_field_coercion",
+ ),
+ ],
+)
+def test_redact(expression, expected):
+ assert redact_asset_expression(expression, readable_asset_ids={1}) ==
expected
+
+
+def test_does_not_mutate_input():
+ expression = {"all": [_VISIBLE, _HIDDEN]}
+ snapshot = copy.deepcopy(expression)
+
+ redacted = redact_asset_expression(expression, readable_asset_ids={1})
+
+ assert expression == snapshot
+ assert redacted is not expression
+ assert redacted["all"][0] is not expression["all"][0]
diff --git
a/airflow-core/tests/unit/api_fastapi/core_api/datamodels/test_common.py
b/airflow-core/tests/unit/api_fastapi/core_api/datamodels/test_common.py
index 2bf6fddb122..d1c1515d6a9 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/datamodels/test_common.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/datamodels/test_common.py
@@ -51,7 +51,17 @@ _REF_BY_URI = {"asset_ref": {"uri": "s3://bucket/key"}}
def test_asset_expression_round_trips_unchanged(expression: dict):
"""The typed model must accept and re-serialize each stored expression
byte-identically."""
validated = _adapter.validate_python(expression)
- assert _adapter.dump_python(validated, by_alias=True) == expression
+ # ``hidden`` is an API-only marker that stored rows never carry, so it is
excluded when unset.
+ assert _adapter.dump_python(validated, by_alias=True, exclude_unset=True)
== expression
+
+
+def test_asset_expression_accepts_redacted_leaf():
+ """A leaf redacted for an unauthorized caller keeps its place in the tree
with blanked identity."""
+ redacted = {"asset": {"uri": None, "name": None, "group": "asset", "id":
None, "hidden": True}}
+ validated = _adapter.validate_python({"all": [_ASSET, redacted]})
+ assert validated.all[1].asset.hidden is True
+ assert validated.all[0].asset.hidden is False
+ assert _adapter.dump_python(validated, by_alias=True)["all"][1] == redacted
def test_asset_expression_tolerates_legacy_asset_leaf_without_id():
@@ -114,4 +124,4 @@ def test_field_preserves_current_shapes(expression):
if expression is None:
assert validated is None
else:
- assert _field_adapter.dump_python(validated, by_alias=True) ==
expression
+ assert _field_adapter.dump_python(validated, by_alias=True,
exclude_unset=True) == expression
diff --git
a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_assets.py
b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_assets.py
index ef4ca97fb2b..e0a87013c4d 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_assets.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_assets.py
@@ -16,6 +16,7 @@
# under the License.
from __future__ import annotations
+import json
from unittest import mock
from unittest.mock import MagicMock
@@ -83,7 +84,8 @@ class TestNextRunAssets:
dag_maker.create_dagrun()
dag_maker.sync_dagbag_to_db()
- with assert_queries_count(4):
+ # 4 queries for the endpoint plus 1 to resolve the assets the caller
may read.
+ with assert_queries_count(5):
response = test_client.get("/next_run_assets/upstream")
assert response.status_code == 200
@@ -96,6 +98,7 @@ class TestNextRunAssets:
"name": "asset1",
"group": "asset",
"id": mock.ANY,
+ "hidden": False,
}
}
]
@@ -118,6 +121,47 @@ class TestNextRunAssets:
"pending_partition_count": None,
}
+ @mock.patch(
+
"airflow.api_fastapi.auth.managers.base_auth_manager.BaseAuthManager.get_authorized_assets",
+ autospec=True,
+ )
+ def test_asset_expression_hides_assets_the_caller_may_not_read(
+ self, mock_get_authorized_assets, test_client, dag_maker, session
+ ):
+ with dag_maker(
+ dag_id="hidden_upstream",
+ schedule=[
+ Asset(uri="s3://bucket/visible", name="visible_asset"),
+ Asset(uri="s3://bucket/hidden", name="hidden_asset"),
+ ],
+ serialized=True,
+ ):
+ EmptyOperator(task_id="task1")
+ dag_maker.sync_dagbag_to_db()
+ visible_id =
session.scalar(select(AssetModel.id).where(AssetModel.name == "visible_asset"))
+ mock_get_authorized_assets.return_value = {visible_id}
+
+ response = test_client.get("/next_run_assets/hidden_upstream")
+
+ assert response.status_code == 200
+ assert response.json()["asset_expression"] == {
+ "all": [
+ {
+ "asset": {
+ "uri": "s3://bucket/visible",
+ "name": "visible_asset",
+ "group": "asset",
+ "id": visible_id,
+ "hidden": False,
+ }
+ },
+ {"asset": {"uri": None, "name": None, "group": "asset", "id":
None, "hidden": True}},
+ ]
+ }
+ redacted = json.dumps(response.json()["asset_expression"])
+ assert "hidden_asset" not in redacted
+ assert "s3://bucket/hidden" not in redacted
+
def test_should_respond_401(self, unauthenticated_test_client):
response = unauthenticated_test_client.get("/next_run_assets/upstream")
assert response.status_code == 401
@@ -170,6 +214,7 @@ class TestNextRunAssets:
"name": "A",
"group": "asset",
"id": mock.ANY,
+ "hidden": False,
}
},
{
@@ -178,6 +223,7 @@ class TestNextRunAssets:
"name": "B",
"group": "asset",
"id": mock.ANY,
+ "hidden": False,
}
},
]
diff --git a/airflow-ctl/src/airflowctl/api/datamodels/generated.py
b/airflow-ctl/src/airflowctl/api/datamodels/generated.py
index 5e340d7667d..cd3210e6699 100644
--- a/airflow-ctl/src/airflowctl/api/datamodels/generated.py
+++ b/airflow-ctl/src/airflowctl/api/datamodels/generated.py
@@ -78,12 +78,17 @@ class AssetExpressionAssetInfo(BaseModel):
persisted; ``BaseAsset.as_expression()`` itself only emits
``uri``/``name``/``group``. It is left
optional so a row persisted before id-enrichment (or migrated from the
pre-3.0 dataset format)
degrades gracefully instead of failing response validation.
+
+ A leaf the caller is not authorized to read is served with ``hidden`` set
and ``uri``, ``name``
+ and ``id`` blanked (see ``airflow.api_fastapi.common.asset_expression``),
so the shape of the
+ schedule stays visible without revealing which asset it waits on.
"""
- uri: Annotated[str, Field(title="Uri")]
- name: Annotated[str, Field(title="Name")]
+ uri: Annotated[str | None, Field(title="Uri")]
+ name: Annotated[str | None, Field(title="Name")]
group: Annotated[str, Field(title="Group")]
id: Annotated[int | None, Field(title="Id")] = None
+ hidden: Annotated[bool | None, Field(title="Hidden")] = False
class AssetExpressionRef(BaseModel):