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):

Reply via email to