This is an automated email from the ASF dual-hosted git repository.
jedcunningham 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 953d7ff3d6a Hide "View error" on bundle files the user cannot read
(#76479)
953d7ff3d6a is described below
commit 953d7ff3d6a741d12a8d63b6d8a4be1b7b94e654
Author: Jed Cunningham <[email protected]>
AuthorDate: Wed Nov 4 13:53:25 2026 -0700
Hide "View error" on bundle files the user cannot read (#76479)
The bundle file list offered "View error" for every import error,
so users without access clicked through to a permission message.
GET /dagBundles/{bundle_name}/files now returns can_read_import_error
per file, using the same rules as GET /importErrors. The UI shows the
button only when it is true, and otherwise a count badge with a
tooltip.
---
.../api_fastapi/core_api/datamodels/dag_bundles.py | 6 ++
.../core_api/openapi/v2-rest-api-generated.yaml | 13 ++++
.../core_api/routes/public/dag_bundles.py | 20 +++++-
.../core_api/services/public/import_errors.py | 76 ++++++++++++++++++++++
.../ui/openapi-gen/queries/ensureQueryData.ts | 3 +
.../src/airflow/ui/openapi-gen/queries/prefetch.ts | 3 +
.../src/airflow/ui/openapi-gen/queries/queries.ts | 3 +
.../src/airflow/ui/openapi-gen/queries/suspense.ts | 3 +
.../airflow/ui/openapi-gen/requests/schemas.gen.ts | 7 +-
.../ui/openapi-gen/requests/services.gen.ts | 3 +
.../airflow/ui/openapi-gen/requests/types.gen.ts | 4 ++
.../ui/src/pages/DagBundle/BundleFiles.test.tsx | 62 ++++++++++++++++++
.../airflow/ui/src/pages/DagBundle/BundleFiles.tsx | 13 +++-
.../core_api/routes/public/test_dag_bundles.py | 67 +++++++++++++++++++
.../src/airflowctl/api/datamodels/generated.py | 7 ++
15 files changed, 286 insertions(+), 4 deletions(-)
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/dag_bundles.py
b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/dag_bundles.py
index 577e3f2f8e4..c439c37845e 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/dag_bundles.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/dag_bundles.py
@@ -95,6 +95,12 @@ class DagBundleFileResponse(BaseModel):
"error itself is authorized separately, through ``GET
/importErrors``."
)
)
+ can_read_import_error: bool = Field(
+ description=(
+ "Whether the caller may read the file's import error through ``GET
/importErrors``, "
+ "possibly with its stack trace redacted. Always false when the
file has no error."
+ ),
+ )
class DagBundleFileCollectionResponse(BaseModel):
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 3c1cee553b1..3157f26fbc3 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
@@ -3566,6 +3566,12 @@ paths:
own permissions.
+ ``can_read_import_error`` is per caller, unlike the counts: it says
whether
+ ``GET /importErrors``
+
+ would return the file''s error to them.
+
+
``dag_count`` counts live Dags only. Dag rows are never deleted: a
file that
fails to import
@@ -16029,6 +16035,12 @@ components:
description: Number of import errors recorded against the file,
which is
at most one. Reading the error itself is authorized separately,
through
``GET /importErrors``.
+ can_read_import_error:
+ type: boolean
+ title: Can Read Import Error
+ description: Whether the caller may read the file's import error
through
+ ``GET /importErrors``, possibly with its stack trace redacted.
Always
+ false when the file has no error.
type: object
required:
- relative_fileloc
@@ -16036,6 +16048,7 @@ components:
- last_parsed_time
- last_parse_duration
- import_error_count
+ - can_read_import_error
title: DagBundleFileResponse
description: A file in a Dag bundle, as the Dag processor last saw it.
DagBundleResponse:
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/dag_bundles.py
b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/dag_bundles.py
index ff1e8f3437f..96ecd0e3f63 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/dag_bundles.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/dag_bundles.py
@@ -34,10 +34,12 @@ from airflow.api_fastapi.core_api.datamodels.dag_bundles
import (
)
from airflow.api_fastapi.core_api.openapi.exceptions import
create_openapi_http_exception_doc
from airflow.api_fastapi.core_api.security import (
+ GetUserDep,
PermittedDagBundleFilter,
ReadableDagBundlesFilterDep,
requires_access_dag_bundles,
)
+from airflow.api_fastapi.core_api.services.public.import_errors import
get_readable_import_error_files
from airflow.configuration import conf
from airflow.models import DagModel
from airflow.models.dagbundle import DagBundleModel
@@ -198,6 +200,7 @@ def get_dag_bundle_files(
offset: QueryOffset,
readable_dag_bundles_filter: ReadableDagBundlesFilterDep,
session: SessionDep,
+ user: GetUserDep,
) -> DagBundleFileCollectionResponse:
"""
List the files in a Dag bundle, ordered by path.
@@ -206,6 +209,9 @@ def get_dag_bundle_files(
authorized as a unit, so its contents are not narrowed to the Dags the
caller may read. What a
file's Dags contain, and the text of its import error, stay behind their
own permissions.
+ ``can_read_import_error`` is per caller, unlike the counts: it says
whether ``GET /importErrors``
+ would return the file's error to them.
+
``dag_count`` counts live Dags only. Dag rows are never deleted: a file
that fails to import
has every Dag in it marked stale, and so does a file dropped from the
bundle. A file therefore
stays listed on the strength of a live Dag *or* an import error, so a file
that has just broken
@@ -273,7 +279,19 @@ def get_dag_bundle_files(
# "unlimited" and return the whole list.
end = None if limit.value is None else start + limit.value
+ page = ordered[start:end]
+ readable_error_files = get_readable_import_error_files(
+ bundle_name,
+ [row["relative_fileloc"] for row in page if row["import_error_count"]],
+ user=user,
+ session=session,
+ )
return DagBundleFileCollectionResponse(
- dag_bundle_files=[DagBundleFileResponse(**row) for row in
ordered[start:end]],
+ dag_bundle_files=[
+ DagBundleFileResponse(
+ **row, can_read_import_error=row["relative_fileloc"] in
readable_error_files
+ )
+ for row in page
+ ],
total_entries=len(ordered),
)
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/services/public/import_errors.py
b/airflow-core/src/airflow/api_fastapi/core_api/services/public/import_errors.py
new file mode 100644
index 00000000000..ce60ff1e891
--- /dev/null
+++
b/airflow-core/src/airflow/api_fastapi/core_api/services/public/import_errors.py
@@ -0,0 +1,76 @@
+# 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
+
+from typing import TYPE_CHECKING
+
+from sqlalchemy import select
+
+from airflow.api_fastapi.app import get_auth_manager
+from airflow.api_fastapi.auth.managers.models.resource_details import
AccessView
+from airflow.models import DagModel
+from airflow.models.dagbundle import DagBundleModel
+
+if TYPE_CHECKING:
+ from collections.abc import Collection
+
+ from sqlalchemy.orm import Session
+
+ from airflow.api_fastapi.auth.managers.models.base_user import BaseUser
+
+
+def get_readable_import_error_files(
+ bundle_name: str, filenames: Collection[str], *, user: BaseUser, session:
Session
+) -> set[str]:
+ """
+ Return the files of ``filenames`` whose import error ``user`` may read
through ``GET /importErrors``.
+
+ Mirrors that endpoint's rules: the caller needs
``AccessView.IMPORT_ERRORS``, and then either
+ read access to at least one Dag registered from the file (stale ones
included), or, for a file
+ that registered no Dag, ``AccessView.IMPORT_ERRORS_ALL`` for the bundle's
team. A file the
+ caller can read only part of still counts, as the endpoint returns it with
a redacted trace.
+ """
+ if not filenames:
+ return set()
+ auth_manager = get_auth_manager()
+ if not
auth_manager.is_authorized_view(access_view=AccessView.IMPORT_ERRORS,
user=user):
+ return set()
+
+ dag_ids_by_file: dict[str, set[str]] = {}
+ for relative_fileloc, dag_id in session.execute(
+ select(DagModel.relative_fileloc, DagModel.dag_id).where(
+ DagModel.bundle_name == bundle_name,
+ DagModel.relative_fileloc.in_(filenames),
+ )
+ ):
+ dag_ids_by_file.setdefault(relative_fileloc, set()).add(dag_id)
+
+ readable: set[str] = set()
+ if dag_ids_by_file:
+ readable_dag_ids = auth_manager.get_authorized_dag_ids(method="GET",
user=user)
+ readable.update(
+ filename for filename, dag_ids in dag_ids_by_file.items() if
dag_ids & readable_dag_ids
+ )
+
+ unregistered = set(filenames) - dag_ids_by_file.keys()
+ if unregistered and auth_manager.authorize_view(
+ access_view=AccessView.IMPORT_ERRORS_ALL,
+ user=user,
+ team_name=DagBundleModel.get_team_name(bundle_name, session=session),
+ ):
+ readable.update(unregistered)
+ return readable
diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts
b/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts
index f27e39d2861..ac59f74f6de 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts
@@ -663,6 +663,9 @@ export const ensureUseDagBundleServiceGetDagBundleData =
(queryClient: QueryClie
* authorized as a unit, so its contents are not narrowed to the Dags the
caller may read. What a
* file's Dags contain, and the text of its import error, stay behind their own
permissions.
*
+* ``can_read_import_error`` is per caller, unlike the counts: it says whether
``GET /importErrors``
+* would return the file's error to them.
+*
* ``dag_count`` counts live Dags only. Dag rows are never deleted: a file that
fails to import
* has every Dag in it marked stale, and so does a file dropped from the
bundle. A file therefore
* stays listed on the strength of a live Dag *or* an import error, so a file
that has just broken
diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts
b/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts
index 563ed761c2f..f27b5787b20 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts
@@ -663,6 +663,9 @@ export const prefetchUseDagBundleServiceGetDagBundle =
(queryClient: QueryClient
* authorized as a unit, so its contents are not narrowed to the Dags the
caller may read. What a
* file's Dags contain, and the text of its import error, stay behind their own
permissions.
*
+* ``can_read_import_error`` is per caller, unlike the counts: it says whether
``GET /importErrors``
+* would return the file's error to them.
+*
* ``dag_count`` counts live Dags only. Dag rows are never deleted: a file that
fails to import
* has every Dag in it marked stale, and so does a file dropped from the
bundle. A file therefore
* stays listed on the strength of a live Dag *or* an import error, so a file
that has just broken
diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts
b/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts
index fb46a748a7a..257458e445a 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts
@@ -663,6 +663,9 @@ export const useDagBundleServiceGetDagBundle = <TData =
Common.DagBundleServiceG
* authorized as a unit, so its contents are not narrowed to the Dags the
caller may read. What a
* file's Dags contain, and the text of its import error, stay behind their own
permissions.
*
+* ``can_read_import_error`` is per caller, unlike the counts: it says whether
``GET /importErrors``
+* would return the file's error to them.
+*
* ``dag_count`` counts live Dags only. Dag rows are never deleted: a file that
fails to import
* has every Dag in it marked stale, and so does a file dropped from the
bundle. A file therefore
* stays listed on the strength of a live Dag *or* an import error, so a file
that has just broken
diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts
b/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts
index 1b7ed80f43a..5151f84eb46 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts
@@ -663,6 +663,9 @@ export const useDagBundleServiceGetDagBundleSuspense =
<TData = Common.DagBundle
* authorized as a unit, so its contents are not narrowed to the Dags the
caller may read. What a
* file's Dags contain, and the text of its import error, stay behind their own
permissions.
*
+* ``can_read_import_error`` is per caller, unlike the counts: it says whether
``GET /importErrors``
+* would return the file's error to them.
+*
* ``dag_count`` counts live Dags only. Dag rows are never deleted: a file that
fails to import
* has every Dag in it marked stale, and so does a file dropped from the
bundle. A file therefore
* stays listed on the strength of a live Dag *or* an import error, so a file
that has just broken
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 1fb52200c5b..f2409050373 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
@@ -4963,10 +4963,15 @@ export const $DagBundleFileResponse = {
type: 'integer',
title: 'Import Error Count',
description: 'Number of import errors recorded against the file,
which is at most one. Reading the error itself is authorized separately,
through ``GET /importErrors``.'
+ },
+ can_read_import_error: {
+ type: 'boolean',
+ title: 'Can Read Import Error',
+ description: "Whether the caller may read the file's import error
through ``GET /importErrors``, possibly with its stack trace redacted. Always
false when the file has no error."
}
},
type: 'object',
- required: ['relative_fileloc', 'dag_count', 'last_parsed_time',
'last_parse_duration', 'import_error_count'],
+ required: ['relative_fileloc', 'dag_count', 'last_parsed_time',
'last_parse_duration', 'import_error_count', 'can_read_import_error'],
title: 'DagBundleFileResponse',
description: 'A file in a Dag bundle, as the Dag processor last saw it.'
} as const;
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/services.gen.ts
b/airflow-core/src/airflow/ui/openapi-gen/requests/services.gen.ts
index a30e0c510fc..ed1743a301f 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/services.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/services.gen.ts
@@ -1730,6 +1730,9 @@ export class DagBundleService {
* authorized as a unit, so its contents are not narrowed to the Dags the
caller may read. What a
* file's Dags contain, and the text of its import error, stay behind
their own permissions.
*
+ * ``can_read_import_error`` is per caller, unlike the counts: it says
whether ``GET /importErrors``
+ * would return the file's error to them.
+ *
* ``dag_count`` counts live Dags only. Dag rows are never deleted: a file
that fails to import
* has every Dag in it marked stale, and so does a file dropped from the
bundle. A file therefore
* stays listed on the strength of a live Dag *or* an import error, so a
file that has just broken
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 02bb7a3595f..3cb19e24e04 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
@@ -1315,6 +1315,10 @@ export type DagBundleFileResponse = {
* Number of import errors recorded against the file, which is at most
one. Reading the error itself is authorized separately, through ``GET
/importErrors``.
*/
import_error_count: number;
+ /**
+ * Whether the caller may read the file's import error through ``GET
/importErrors``, possibly with its stack trace redacted. Always false when the
file has no error.
+ */
+ can_read_import_error: boolean;
};
/**
diff --git
a/airflow-core/src/airflow/ui/src/pages/DagBundle/BundleFiles.test.tsx
b/airflow-core/src/airflow/ui/src/pages/DagBundle/BundleFiles.test.tsx
new file mode 100644
index 00000000000..f8ce54ce6c7
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/DagBundle/BundleFiles.test.tsx
@@ -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.
+ */
+import "@testing-library/jest-dom/vitest";
+import { render, screen } from "@testing-library/react";
+import { http, HttpResponse } from "msw";
+import { describe, expect, it } from "vitest";
+
+import { server } from "src/mocks/server";
+import { Wrapper } from "src/utils/Wrapper";
+
+import { BundleFiles } from "./BundleFiles";
+
+const fileRow = (relativeFileloc: string, canReadImportError: boolean) => ({
+ can_read_import_error: canReadImportError,
+ dag_count: 1,
+ import_error_count: 1,
+ last_parse_duration: null,
+ last_parsed_time: null,
+ relative_fileloc: relativeFileloc,
+});
+
+describe("BundleFiles import error column", () => {
+ it("offers the error only where the caller may read it", async () => {
+ server.use(
+ http.get("/api/v2/dagBundles/:bundleName/files", () =>
+ HttpResponse.json({
+ dag_bundle_files: [fileRow("readable.py", true),
fileRow("hidden.py", false)],
+ total_entries: 2,
+ }),
+ ),
+ );
+
+ render(<BundleFiles bundleName="my-bundle" />, { wrapper: Wrapper });
+
+ expect(await screen.findByText("hidden.py")).toBeInTheDocument();
+ expect(screen.getAllByRole("button", { name:
"browse:dagBundles.files.showImportError" })).toHaveLength(
+ 1,
+ );
+ // The unreadable file still shows that it is broken, as a count rather
than a control.
+ const hiddenRow = screen.getByText("hidden.py").closest("tr");
+
+ expect(hiddenRow).not.toBeNull();
+ expect(hiddenRow?.querySelector("button")).toBeNull();
+ expect(hiddenRow).toHaveTextContent("1");
+ });
+});
diff --git a/airflow-core/src/airflow/ui/src/pages/DagBundle/BundleFiles.tsx
b/airflow-core/src/airflow/ui/src/pages/DagBundle/BundleFiles.tsx
index e46409250d1..f83de3b666a 100644
--- a/airflow-core/src/airflow/ui/src/pages/DagBundle/BundleFiles.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/DagBundle/BundleFiles.tsx
@@ -18,7 +18,7 @@
*/
import { useState } from "react";
-import { Box, Button, Text } from "@chakra-ui/react";
+import { Badge, Box, Button, Text } from "@chakra-ui/react";
import type { ColumnDef } from "@tanstack/react-table";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
@@ -27,6 +27,8 @@ import { LuFileWarning } from "react-icons/lu";
import { useDagBundleServiceGetDagBundleFiles } from "openapi/queries";
import type { DagBundleFileResponse } from "openapi/requests/types.gen";
+import { Tooltip } from "src/system-components";
+
import { DataTable } from "src/components/DataTable";
import type { DataTableFeatures } from "src/components/DataTable/features";
import { useTableURLState } from "src/components/DataTable/useTableUrlState";
@@ -85,7 +87,7 @@ const createColumns = (
// status rather than a control, so the clickable form carries an icon
and its own label.
original.import_error_count === 0 ? (
<ImportErrorCount count={original.import_error_count} />
- ) : (
+ ) : original.can_read_import_error ? (
<Button
colorPalette="failed"
onClick={() => {
@@ -99,6 +101,13 @@ const createColumns = (
count: original.import_error_count,
})}
</Button>
+ ) : (
+ <Tooltip
content={translate("browse:dagBundles.files.importErrorForbidden")}>
+ <Badge colorPalette="failed" h={8} variant="subtle">
+ <LuFileWarning />
+ {formatNumber(original.import_error_count, locale)}
+ </Badge>
+ </Tooltip>
),
enableSorting: false,
header: translate("browse:dagBundles.columns.importErrors"),
diff --git
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_bundles.py
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_bundles.py
index 218b9fde5ed..ddc6730771f 100644
---
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_bundles.py
+++
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_bundles.py
@@ -672,3 +672,70 @@ class TestGetDagBundleFiles:
def test_404_for_an_unknown_bundle(self, test_client):
assert test_client.get("/dagBundles/no_such_bundle/files").status_code
== 404
+
+ def test_an_admin_may_read_every_import_error(self, test_client):
+ body = test_client.get(f"/dagBundles/{GIT_BUNDLE}/files").json()
+
+ assert {
+ file["relative_fileloc"]: file["can_read_import_error"] for file
in body["dag_bundle_files"]
+ } == {
+ UNREGISTERED_FILE: True,
+ REGISTERED_FILE: True,
+ SECOND_REGISTERED_FILE: True,
+ }
+
+ def test_a_file_with_no_error_has_nothing_to_read(self, test_client):
+ body = test_client.get(f"/dagBundles/{LOCAL_BUNDLE}/files").json()
+
+ assert [file["can_read_import_error"] for file in
body["dag_bundle_files"]] == [False]
+
+ def
test_an_op_may_not_read_the_error_of_a_file_that_registered_no_dag(self,
op_headers, test_client):
+ """Such errors sit behind ``IMPORT_ERRORS_ALL``, which an op does not
hold."""
+ body = test_client.get(f"/dagBundles/{GIT_BUNDLE}/files",
headers=op_headers).json()
+
+ assert {
+ file["relative_fileloc"]: file["can_read_import_error"] for file
in body["dag_bundle_files"]
+ } == {
+ UNREGISTERED_FILE: False,
+ REGISTERED_FILE: True,
+ SECOND_REGISTERED_FILE: True,
+ }
+
+ def test_follows_the_dags_the_caller_may_read(self, test_client):
+ """
+ One readable Dag is enough, even beside an unreadable one, as the
error is then redacted.
+
+ ``dag_0.py`` also holds a stale Dag outside the readable set;
``second.py`` holds none
+ the caller may read.
+ """
+ with mock.patch.object(
+ test_client.app.state.auth_manager,
+ "get_authorized_dag_ids",
+ autospec=True,
+ return_value={f"dag_in_{GIT_BUNDLE}"},
+ ):
+ body = test_client.get(f"/dagBundles/{GIT_BUNDLE}/files").json()
+
+ by_path = {
+ file["relative_fileloc"]: file["can_read_import_error"] for file
in body["dag_bundle_files"]
+ }
+ assert by_path[REGISTERED_FILE] is True
+ assert by_path[SECOND_REGISTERED_FILE] is False
+
+ def test_no_import_error_is_readable_without_the_import_errors_view(self,
test_client):
+ auth_manager = test_client.app.state.auth_manager
+ real_is_authorized_view = auth_manager.is_authorized_view
+
+ def is_authorized_view(*, access_view, user, team_name=None):
+ if access_view is AccessView.IMPORT_ERRORS:
+ return False
+ return real_is_authorized_view(access_view=access_view, user=user,
team_name=team_name)
+
+ with mock.patch.object(
+ auth_manager, "is_authorized_view", autospec=True,
side_effect=is_authorized_view
+ ):
+ body = test_client.get(f"/dagBundles/{GIT_BUNDLE}/files").json()
+
+ assert [file["can_read_import_error"] for file in
body["dag_bundle_files"]] == [False, False, False]
+ # The counts are the bundle's and stay put.
+ assert [file["import_error_count"] for file in
body["dag_bundle_files"]] == [1, 1, 1]
diff --git a/airflow-ctl/src/airflowctl/api/datamodels/generated.py
b/airflow-ctl/src/airflowctl/api/datamodels/generated.py
index 21d095a767e..255720ef18f 100644
--- a/airflow-ctl/src/airflowctl/api/datamodels/generated.py
+++ b/airflow-ctl/src/airflowctl/api/datamodels/generated.py
@@ -630,6 +630,13 @@ class DagBundleFileResponse(BaseModel):
title="Import Error Count",
),
]
+ can_read_import_error: Annotated[
+ bool,
+ Field(
+ description="Whether the caller may read the file's import error
through ``GET /importErrors``, possibly with its stack trace redacted. Always
false when the file has no error.",
+ title="Can Read Import Error",
+ ),
+ ]
class DagBundleResponse(BaseModel):