jedcunningham commented on code in PR #76329: URL: https://github.com/apache/airflow/pull/76329#discussion_r4450514622
########## airflow-core/src/airflow/api_fastapi/core_api/routes/ui/dag_bundles.py: ########## @@ -0,0 +1,44 @@ +# 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 fastapi import Depends +from sqlalchemy import func, select + +from airflow.api_fastapi.common.db.common import SessionDep +from airflow.api_fastapi.common.router import AirflowRouter +from airflow.api_fastapi.core_api.datamodels.ui.dag_bundles import DagBundleCountResponse +from airflow.api_fastapi.core_api.security import ReadableDagsFilterDep, requires_access_dag +from airflow.models import DagModel + +dag_bundles_router = AirflowRouter(tags=["Dag Bundle"], prefix="/dagBundles") + + +@dag_bundles_router.get("/count", dependencies=[Depends(requires_access_dag(method="GET"))]) +def get_dag_bundle_count( + readable_dags_filter: ReadableDagsFilterDep, + session: SessionDep, +) -> DagBundleCountResponse: + """ + Count the Dag bundles holding a Dag the caller may read. + + Readable by anyone who can read Dags, unlike ``GET /dagBundles``, so the UI can tell whether a + bundle name is worth showing next to an import error. A bundle holding only stale Dags is + counted too: that is a bundle whose every file fails to import, where the name matters most. + """ + count = session.scalar(readable_dags_filter.to_orm(select(func.count(DagModel.bundle_name.distinct())))) Review Comment: Fixed. The count now includes bundles with readable import errors, and there's a test. ########## providers/keycloak/src/airflow/providers/keycloak/auth_manager/keycloak_auth_manager.py: ########## @@ -91,9 +91,10 @@ RESOURCE_ID_ATTRIBUTE_NAME = "resource_id" -# ``AccessView`` values covering records that carry no per-Dag or per-team key to authorize on. They -# are admin-by-default in core. Compared by value because older Airflow versions lack some members. -ADMIN_ACCESS_VIEW_NAMES = frozenset({"AUDIT_LOGS_ALL", "IMPORT_ERRORS_ALL", "REPARSE_ALL"}) +# ``AccessView`` values showing records beyond the Dags a caller can read. In multi-team mode they +# are checked against ``AdminView``, which only ``SuperAdmin`` holds. Compared by value because +# older Airflow versions lack some members. +ADMIN_ACCESS_VIEW_NAMES = frozenset({"AUDIT_LOGS_ALL", "DAG_BUNDLES", "IMPORT_ERRORS_ALL", "REPARSE_ALL"}) Review Comment: Added a changelog note and updated `permissions.rst`. ########## airflow-core/src/airflow/api_fastapi/core_api/security.py: ########## @@ -1284,6 +1270,26 @@ def inner( return inner +def requires_access_dag_bundles() -> Callable[[BaseUser, BaseAuthManager], None]: + """ + Require ``AccessView.DAG_BUNDLES``, without a team. + + In a multi-team deployment this only admits the caller to the route, and + ``ReadableDagBundlesFilterDep`` then narrows the bundles to the caller's teams. Goes through + ``authorize_view`` rather than ``requires_access_view`` so that an auth manager that cannot map + the view (raises ``KeyError``) denies it instead of failing the request. + """ + + def inner(user: GetUserDep, auth_manager: AuthManagerDep) -> None: + _requires_access( + is_authorized_callback=lambda: auth_manager.authorize_view( + access_view=AccessView.DAG_BUNDLES, user=user Review Comment: Done. The gate now admits anyone holding the view for any of their teams. ########## airflow-core/src/airflow/ui/src/pages/DagBundle/FileImportError.tsx: ########## @@ -40,25 +47,55 @@ type Props = { * error. A dialog rather than a page of its own, because a single stack trace is not worth an * addressable route. */ -export const FileImportError = ({ bundleName, onClose, relativeFileloc }: Props) => { +export const FileImportError = ({ + bundleName, + hasImportError, + onClose, + recheckImportError, + relativeFileloc, +}: Props) => { const { t: translate } = useTranslation("browse"); + const [recheckedFileloc, setRecheckedFileloc] = useState<string | undefined>(undefined); - const { data, isLoading } = useImportErrorServiceGetImportErrors( + const { data, error, isLoading } = useImportErrorServiceGetImportErrors( { bundleName, filename: relativeFileloc ?? "" }, undefined, { enabled: relativeFileloc !== undefined }, ); const [importError] = data?.import_errors ?? []; + // ``GET /importErrors`` leaves out errors the caller may not read, so an empty result is told + // apart by the file list, whose count is not filtered by caller. The badge was clicked on a + // count that may predate a fix, so the list is refetched once before the count is trusted. + const needsRecheck = + relativeFileloc !== undefined && + data !== undefined && + importError === undefined && + hasImportError && + recheckedFileloc !== relativeFileloc; + + useEffect(() => { + if (needsRecheck) { + void recheckImportError().finally(() => { + setRecheckedFileloc(relativeFileloc); + }); + } + }, [needsRecheck, recheckImportError, relativeFileloc]); + + const close = () => { + setRecheckedFileloc(undefined); + onClose(); + }; + if (importError !== undefined) { return ( - <DagImportErrorModal importError={importError} onClose={onClose} open={relativeFileloc !== undefined} /> + <DagImportErrorModal importError={importError} onClose={close} open={relativeFileloc !== undefined} /> ); } - // The table polls, so the error can be gone by the time its badge is clicked. Say so rather - // than leaving the click with no visible effect. + const isForbidden = getErrorStatus(error) === 403 || (data !== undefined && hasImportError); Review Comment: Fixed. The recheck refetches the import error too. ########## airflow-core/src/airflow/api_fastapi/core_api/datamodels/dag_bundles.py: ########## @@ -44,17 +44,13 @@ class DagBundleResponse(BaseModel): ) ) bundle_url: str | None = Field( - description=( - "A link to view the bundle at ``version``, when one is configured and the caller may " - "read Dag versions." - ) + description=("A link to view the bundle at ``version``, when the bundle is configured with one.") Review Comment: Updated. -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
