This is an automated email from the ASF dual-hosted git repository.
potiuk pushed a commit to branch v3-3-test
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/v3-3-test by this push:
new 18f05a4af4c [v3-3-test] Automate stable REST API permission reference
doc generation (#67606) (#69334)
18f05a4af4c is described below
commit 18f05a4af4c53af4e2afae6f8b550fdb8504d412
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Fri Jul 3 20:16:21 2026 +0200
[v3-3-test] Automate stable REST API permission reference doc generation
(#67606) (#69334)
* Generate stable REST API permission reference docs automatically
* Fix mypy typing issue in permission extractor
* chore: rerun flaky CI
* Add prek hook and sort generated API permission docs
* Potential fix for pull request finding
* Handle positional DAG access entity extraction
* Include public API endpoints in permission reference docs
* Update generated REST API permission reference
* Fix multi-router path extraction in permission docs
---------
(cherry picked from commit 63b98266c9e1e80c173983d52d8504aca3b3a5f1)
Co-authored-by: Durgaprasad M L <[email protected]>
Co-authored-by: Copilot Autofix powered by AI
<[email protected]>
---
.pre-commit-config.yaml | 11 +
airflow-core/docs/security/api_permissions_ref.rst | 576 ++++++++++++++
scripts/ci/prek/extract_permissions.py | 550 ++++++++++++++
scripts/tests/ci/prek/test_extract_permissions.py | 832 +++++++++++++++++++++
4 files changed, 1969 insertions(+)
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index f3461ed417c..95bb77f184c 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -644,6 +644,17 @@ repos:
language: python
files:
^airflow-core/docs/installation/supported-versions\.rst$|^scripts/ci/prek/supported_versions\.py$|^README\.md$
pass_filenames: false
+ - id: generate-api-permissions-doc
+ name: Generate REST API permission reference documentation
+ entry: ./scripts/ci/prek/extract_permissions.py
+ language: python
+ files: >
+ (?x)
+ ^airflow-core/src/airflow/api_fastapi/core_api/routes/public/.*\.py$|
+ ^airflow-core/src/airflow/api_fastapi/core_api/security\.py$|
+ ^scripts/ci/prek/extract_permissions\.py$|
+ ^airflow-core/docs/security/api_permissions_ref\.rst$
+ pass_filenames: false
- id: check-revision-heads-map
name: Check that the REVISION_HEADS_MAP is up-to-date
language: python
diff --git a/airflow-core/docs/security/api_permissions_ref.rst
b/airflow-core/docs/security/api_permissions_ref.rst
new file mode 100644
index 00000000000..11097ad5a42
--- /dev/null
+++ b/airflow-core/docs/security/api_permissions_ref.rst
@@ -0,0 +1,576 @@
+ .. 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.
+
+.. THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+ Regenerate with: python scripts/ci/prek/extract_permissions.py
+ Trigger: prek run generate-api-permissions-doc --all-files
+
+API Endpoint Permission Reference
+==================================
+
+This page lists the required permission for every endpoint in the stable
+Airflow REST API (``/api/v2``). It is generated automatically from the
+source code so it stays up to date as endpoints are added or changed.
+
+.. seealso::
+
+ :doc:`/security/api` — for authentication instructions (JWT tokens).
+
+.. note::
+
+ Permissions are enforced by the configured **auth manager**. The
+
:class:`~airflow.api_fastapi.auth.managers.base_auth_manager.BaseAuthManager`
+ interface defines the contract; individual auth manager implementations
+ (e.g. the Simple Auth Manager, or the FAB provider) translate these
+ resource/method tuples into their own role/permission models.
+
+.. list-table:: Stable REST API endpoint permissions
+ :header-rows: 1
+ :widths: 7 50 20 13
+
+ * - Method
+ - Endpoint path
+ - Resource
+ - Required permission
+ * - ``GET``
+ - ``/api/v2/assets``
+ - ``Asset``
+ - ``GET``
+ * - ``GET``
+ - ``/api/v2/assets``
+ - ``AssetAlias``
+ - ``GET``
+ * - ``GET``
+ - ``/api/v2/assets/aliases``
+ - ``AssetAlias``
+ - ``GET``
+ * - ``GET``
+ - ``/api/v2/assets/aliases/{asset_alias_id}``
+ - ``AssetAlias``
+ - ``GET``
+ * - ``GET``
+ - ``/api/v2/assets/events``
+ - ``Asset``
+ - ``GET``
+ * - ``POST``
+ - ``/api/v2/assets/events``
+ - ``Asset``
+ - ``POST``
+ * - ``GET``
+ - ``/api/v2/assets/{asset_id}``
+ - ``Asset``
+ - ``GET``
+ * - ``GET``
+ - ``/api/v2/assets/{asset_id}``
+ - ``AssetAlias``
+ - ``GET``
+ * - ``POST``
+ - ``/api/v2/assets/{asset_id}/materialize``
+ - ``Asset``
+ - ``POST``
+ * - ``DELETE``
+ - ``/api/v2/assets/{asset_id}/queuedEvents``
+ - ``Asset``
+ - ``DELETE``
+ * - ``DELETE``
+ - ``/api/v2/assets/{asset_id}/queuedEvents``
+ - ``DAG``
+ - ``GET``
+ * - ``GET``
+ - ``/api/v2/assets/{asset_id}/queuedEvents``
+ - ``Asset``
+ - ``GET``
+ * - ``DELETE``
+ - ``/api/v2/assets/{asset_id}/state-store``
+ - ``Asset``
+ - ``DELETE``
+ * - ``GET``
+ - ``/api/v2/assets/{asset_id}/state-store``
+ - ``Asset``
+ - ``GET``
+ * - ``DELETE``
+ - ``/api/v2/assets/{asset_id}/state-store/{key:path}``
+ - ``Asset``
+ - ``DELETE``
+ * - ``GET``
+ - ``/api/v2/assets/{asset_id}/state-store/{key:path}``
+ - ``Asset``
+ - ``GET``
+ * - ``PUT``
+ - ``/api/v2/assets/{asset_id}/state-store/{key:path}``
+ - ``Asset``
+ - ``PUT``
+ * - ``GET``
+ - ``/api/v2/auth/login``
+ - ``Public``
+ - ``No Airflow permission required``
+ * - ``GET``
+ - ``/api/v2/auth/logout``
+ - ``Public``
+ - ``No Airflow permission required``
+ * - ``GET``
+ - ``/api/v2/backfills``
+ - ``DAG.RUN``
+ - ``GET``
+ * - ``POST``
+ - ``/api/v2/backfills``
+ - ``DAG.RUN``
+ - ``POST``
+ * - ``PUT``
+ - ``/api/v2/backfills``
+ - ``DAG.RUN``
+ - ``PUT``
+ * - ``GET``
+ - ``/api/v2/config``
+ - ``Configuration``
+ - ``GET``
+ * - ``GET``
+ - ``/api/v2/config/section/{section}/option/{option}``
+ - ``Configuration``
+ - ``GET``
+ * - ``GET``
+ - ``/api/v2/connections``
+ - ``Connection``
+ - ``GET``
+ * - ``PATCH``
+ - ``/api/v2/connections``
+ - ``Connection``
+ - ``multi``
+ * - ``POST``
+ - ``/api/v2/connections``
+ - ``Connection``
+ - ``POST``
+ * - ``POST``
+ - ``/api/v2/connections/defaults``
+ - ``Connection``
+ - ``POST``
+ * - ``GET``
+ - ``/api/v2/connections/enqueue-test``
+ - ``Public``
+ - ``No Airflow permission required``
+ * - ``POST``
+ - ``/api/v2/connections/enqueue-test``
+ - ``Public``
+ - ``No Airflow permission required``
+ * - ``POST``
+ - ``/api/v2/connections/test``
+ - ``Connection``
+ - ``POST``
+ * - ``DELETE``
+ - ``/api/v2/connections/{connection_id}``
+ - ``Connection``
+ - ``DELETE``
+ * - ``GET``
+ - ``/api/v2/connections/{connection_id}``
+ - ``Connection``
+ - ``GET``
+ * - ``PATCH``
+ - ``/api/v2/connections/{connection_id}``
+ - ``Connection``
+ - ``PUT``
+ * - ``GET``
+ - ``/api/v2/dagSources/{dag_id}``
+ - ``DAG.CODE``
+ - ``GET``
+ * - ``GET``
+ - ``/api/v2/dagStats``
+ - ``DAG.RUN``
+ - ``GET``
+ * - ``GET``
+ - ``/api/v2/dagTags``
+ - ``DAG``
+ - ``GET``
+ * - ``GET``
+ - ``/api/v2/dagWarnings``
+ - ``DAG.WARNING``
+ - ``GET``
+ * - ``GET``
+ - ``/api/v2/dags``
+ - ``DAG``
+ - ``GET``
+ * - ``PATCH``
+ - ``/api/v2/dags``
+ - ``DAG``
+ - ``PUT``
+ * - ``DELETE``
+ - ``/api/v2/dags/{dag_id}``
+ - ``DAG``
+ - ``DELETE``
+ * - ``GET``
+ - ``/api/v2/dags/{dag_id}``
+ - ``DAG``
+ - ``GET``
+ * - ``PATCH``
+ - ``/api/v2/dags/{dag_id}``
+ - ``DAG``
+ - ``PUT``
+ * - ``DELETE``
+ - ``/api/v2/dags/{dag_id}/assets/queuedEvents``
+ - ``Asset``
+ - ``DELETE``
+ * - ``DELETE``
+ - ``/api/v2/dags/{dag_id}/assets/queuedEvents``
+ - ``DAG``
+ - ``GET``
+ * - ``GET``
+ - ``/api/v2/dags/{dag_id}/assets/queuedEvents``
+ - ``Asset``
+ - ``GET``
+ * - ``GET``
+ - ``/api/v2/dags/{dag_id}/assets/queuedEvents``
+ - ``DAG``
+ - ``GET``
+ * - ``DELETE``
+ - ``/api/v2/dags/{dag_id}/assets/{asset_id}/queuedEvents``
+ - ``Asset``
+ - ``DELETE``
+ * - ``DELETE``
+ - ``/api/v2/dags/{dag_id}/assets/{asset_id}/queuedEvents``
+ - ``DAG``
+ - ``GET``
+ * - ``GET``
+ - ``/api/v2/dags/{dag_id}/assets/{asset_id}/queuedEvents``
+ - ``Asset``
+ - ``GET``
+ * - ``GET``
+ - ``/api/v2/dags/{dag_id}/assets/{asset_id}/queuedEvents``
+ - ``DAG``
+ - ``GET``
+ * - ``POST``
+ - ``/api/v2/dags/{dag_id}/clearDagRuns``
+ - ``DAG.RUN``
+ - ``multi``
+ * - ``POST``
+ - ``/api/v2/dags/{dag_id}/clearPartitions``
+ - ``DAG.RUN``
+ - ``PUT``
+ * - ``POST``
+ - ``/api/v2/dags/{dag_id}/clearTaskInstances``
+ - ``DAG.TASK_INSTANCE``
+ - ``PUT``
+ * - ``GET``
+ - ``/api/v2/dags/{dag_id}/dagRuns``
+ - ``DAG.RUN``
+ - ``GET``
+ * - ``PATCH``
+ - ``/api/v2/dags/{dag_id}/dagRuns``
+ - ``DAG.RUN``
+ - ``multi``
+ * - ``POST``
+ - ``/api/v2/dags/{dag_id}/dagRuns``
+ - ``DAG.RUN``
+ - ``POST``
+ * - ``POST``
+ - ``/api/v2/dags/{dag_id}/dagRuns/list``
+ - ``DAG.RUN``
+ - ``GET``
+ * - ``DELETE``
+ - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}``
+ - ``DAG.RUN``
+ - ``DELETE``
+ * - ``GET``
+ - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}``
+ - ``DAG.RUN``
+ - ``GET``
+ * - ``PATCH``
+ - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}``
+ - ``DAG.RUN``
+ - ``PUT``
+ * - ``POST``
+ - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/clear``
+ - ``DAG.RUN``
+ - ``PUT``
+ * - ``GET``
+ - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/hitlDetails``
+ - ``DAG.HITL_DETAIL``
+ - ``GET``
+ * - ``PATCH``
+ -
``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskGroupInstances/{group_id}``
+ - ``DAG.TASK_INSTANCE``
+ - ``PUT``
+ * - ``PATCH``
+ -
``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskGroupInstances/{group_id}/dry_run``
+ - ``DAG.TASK_INSTANCE``
+ - ``PUT``
+ * - ``GET``
+ - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances``
+ - ``DAG.TASK_INSTANCE``
+ - ``GET``
+ * - ``PATCH``
+ - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances``
+ - ``DAG.TASK_INSTANCE``
+ - ``PUT``
+ * - ``POST``
+ - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/list``
+ - ``DAG.TASK_INSTANCE``
+ - ``GET``
+ * - ``DELETE``
+ - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}``
+ - ``DAG.TASK_INSTANCE``
+ - ``DELETE``
+ * - ``GET``
+ - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}``
+ - ``DAG.TASK_INSTANCE``
+ - ``GET``
+ * - ``PATCH``
+ - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}``
+ - ``DAG.TASK_INSTANCE``
+ - ``PUT``
+ * - ``GET``
+ -
``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/dependencies``
+ - ``DAG.TASK_INSTANCE``
+ - ``GET``
+ * - ``PATCH``
+ -
``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/dry_run``
+ - ``DAG.TASK_INSTANCE``
+ - ``PUT``
+ * - ``GET``
+ -
``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/externalLogUrl/{try_number}``
+ - ``DAG.TASK_LOGS``
+ - ``GET``
+ * - ``GET``
+ -
``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/links``
+ - ``DAG.TASK_INSTANCE``
+ - ``GET``
+ * - ``GET``
+ -
``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/listMapped``
+ - ``DAG.TASK_INSTANCE``
+ - ``GET``
+ * - ``GET``
+ -
``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/logs/{try_number}``
+ - ``DAG.TASK_LOGS``
+ - ``GET``
+ * - ``DELETE``
+ -
``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/state-store``
+ - ``DAG.TASK_INSTANCE``
+ - ``DELETE``
+ * - ``GET``
+ -
``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/state-store``
+ - ``DAG.TASK_INSTANCE``
+ - ``GET``
+ * - ``DELETE``
+ -
``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/state-store/{key:path}``
+ - ``DAG.TASK_INSTANCE``
+ - ``DELETE``
+ * - ``GET``
+ -
``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/state-store/{key:path}``
+ - ``DAG.TASK_INSTANCE``
+ - ``GET``
+ * - ``PATCH``
+ -
``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/state-store/{key:path}``
+ - ``DAG.TASK_INSTANCE``
+ - ``PUT``
+ * - ``PUT``
+ -
``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/state-store/{key:path}``
+ - ``DAG.TASK_INSTANCE``
+ - ``PUT``
+ * - ``GET``
+ -
``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/tries``
+ - ``DAG.TASK_INSTANCE``
+ - ``GET``
+ * - ``GET``
+ -
``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/tries/{task_try_number}``
+ - ``DAG.TASK_INSTANCE``
+ - ``GET``
+ * - ``GET``
+ -
``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/xcomEntries``
+ - ``DAG.XCOM``
+ - ``GET``
+ * - ``POST``
+ -
``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/xcomEntries``
+ - ``DAG.XCOM``
+ - ``POST``
+ * - ``DELETE``
+ -
``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/xcomEntries/{xcom_key:path}``
+ - ``DAG.XCOM``
+ - ``DELETE``
+ * - ``GET``
+ -
``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/xcomEntries/{xcom_key:path}``
+ - ``DAG.XCOM``
+ - ``GET``
+ * - ``PATCH``
+ -
``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/xcomEntries/{xcom_key:path}``
+ - ``DAG.XCOM``
+ - ``PUT``
+ * - ``GET``
+ -
``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/{map_index}``
+ - ``DAG.TASK_INSTANCE``
+ - ``GET``
+ * - ``PATCH``
+ -
``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/{map_index}``
+ - ``DAG.TASK_INSTANCE``
+ - ``PUT``
+ * - ``GET``
+ -
``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/{map_index}/dependencies``
+ - ``DAG.TASK_INSTANCE``
+ - ``GET``
+ * - ``PATCH``
+ -
``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/{map_index}/dry_run``
+ - ``DAG.TASK_INSTANCE``
+ - ``PUT``
+ * - ``GET``
+ -
``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/{map_index}/hitlDetails``
+ - ``DAG.HITL_DETAIL``
+ - ``GET``
+ * - ``PATCH``
+ -
``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/{map_index}/hitlDetails``
+ - ``DAG.HITL_DETAIL``
+ - ``PUT``
+ * - ``GET``
+ -
``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/{map_index}/hitlDetails/tries/{try_number}``
+ - ``DAG.HITL_DETAIL``
+ - ``GET``
+ * - ``GET``
+ -
``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/{map_index}/tries``
+ - ``DAG.TASK_INSTANCE``
+ - ``GET``
+ * - ``GET``
+ -
``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/{map_index}/tries/{task_try_number}``
+ - ``DAG.TASK_INSTANCE``
+ - ``GET``
+ * - ``GET``
+ - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/upstreamAssetEvents``
+ - ``Asset``
+ - ``GET``
+ * - ``GET``
+ - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/upstreamAssetEvents``
+ - ``DAG.RUN``
+ - ``GET``
+ * - ``GET``
+ - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/wait``
+ - ``DAG.RUN``
+ - ``GET``
+ * - ``GET``
+ - ``/api/v2/dags/{dag_id}/dagVersions``
+ - ``DAG.VERSION``
+ - ``GET``
+ * - ``GET``
+ - ``/api/v2/dags/{dag_id}/dagVersions/{version_number}``
+ - ``DAG.VERSION``
+ - ``GET``
+ * - ``GET``
+ - ``/api/v2/dags/{dag_id}/details``
+ - ``DAG``
+ - ``GET``
+ * - ``POST``
+ - ``/api/v2/dags/{dag_id}/favorite``
+ - ``DAG``
+ - ``GET``
+ * - ``GET``
+ - ``/api/v2/dags/{dag_id}/tasks``
+ - ``DAG.TASK``
+ - ``GET``
+ * - ``GET``
+ - ``/api/v2/dags/{dag_id}/tasks/{task_id}``
+ - ``DAG.TASK``
+ - ``GET``
+ * - ``POST``
+ - ``/api/v2/dags/{dag_id}/unfavorite``
+ - ``DAG``
+ - ``GET``
+ * - ``GET``
+ - ``/api/v2/eventLogs``
+ - ``DAG.AUDIT_LOG``
+ - ``GET``
+ * - ``GET``
+ - ``/api/v2/eventLogs/{event_log_id}``
+ - ``DAG.AUDIT_LOG``
+ - ``GET``
+ * - ``GET``
+ - ``/api/v2/importErrors``
+ - ``View.IMPORT_ERRORS``
+ - ``IMPORT_ERRORS``
+ * - ``GET``
+ - ``/api/v2/importErrors/{import_error_id}``
+ - ``View.IMPORT_ERRORS``
+ - ``IMPORT_ERRORS``
+ * - ``GET``
+ - ``/api/v2/jobs``
+ - ``View.JOBS``
+ - ``JOBS``
+ * - ``GET``
+ - ``/api/v2/monitor/health``
+ - ``Public``
+ - ``No Airflow permission required``
+ * - ``PUT``
+ - ``/api/v2/parseDagFile/{file_token}``
+ - ``DAG``
+ - ``PUT``
+ * - ``GET``
+ - ``/api/v2/plugins``
+ - ``View.PLUGINS``
+ - ``PLUGINS``
+ * - ``GET``
+ - ``/api/v2/plugins/importErrors``
+ - ``View.PLUGINS``
+ - ``PLUGINS``
+ * - ``GET``
+ - ``/api/v2/pools``
+ - ``Pool``
+ - ``GET``
+ * - ``PATCH``
+ - ``/api/v2/pools``
+ - ``Pool``
+ - ``multi``
+ * - ``POST``
+ - ``/api/v2/pools``
+ - ``Pool``
+ - ``POST``
+ * - ``DELETE``
+ - ``/api/v2/pools/{pool_name:path}``
+ - ``Pool``
+ - ``DELETE``
+ * - ``GET``
+ - ``/api/v2/pools/{pool_name:path}``
+ - ``Pool``
+ - ``GET``
+ * - ``PATCH``
+ - ``/api/v2/pools/{pool_name:path}``
+ - ``Pool``
+ - ``PUT``
+ * - ``GET``
+ - ``/api/v2/providers``
+ - ``View.PROVIDERS``
+ - ``PROVIDERS``
+ * - ``GET``
+ - ``/api/v2/variables``
+ - ``Variable``
+ - ``GET``
+ * - ``PATCH``
+ - ``/api/v2/variables``
+ - ``Variable``
+ - ``multi``
+ * - ``POST``
+ - ``/api/v2/variables``
+ - ``Variable``
+ - ``POST``
+ * - ``DELETE``
+ - ``/api/v2/variables/{variable_key:path}``
+ - ``Variable``
+ - ``DELETE``
+ * - ``GET``
+ - ``/api/v2/variables/{variable_key:path}``
+ - ``Variable``
+ - ``GET``
+ * - ``PATCH``
+ - ``/api/v2/variables/{variable_key:path}``
+ - ``Variable``
+ - ``PUT``
+ * - ``GET``
+ - ``/api/v2/version``
+ - ``Public``
+ - ``No Airflow permission required``
diff --git a/scripts/ci/prek/extract_permissions.py
b/scripts/ci/prek/extract_permissions.py
new file mode 100644
index 00000000000..e9e454fe299
--- /dev/null
+++ b/scripts/ci/prek/extract_permissions.py
@@ -0,0 +1,550 @@
+#!/usr/bin/env python
+# 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.
+"""
+Extract permission requirements from FastAPI routes in Airflow REST API.
+
+This script statically parses FastAPI route files under airflow-core's public
REST API
+routes to extract required permissions for each endpoint. It generates a
reference
+RST documentation file for security/api_permissions_ref.rst.
+
+It runs completely statically using Python's built-in AST parser, requiring no
runtime
+Airflow imports or active execution environment, making it suitable for CI
checks.
+"""
+
+from __future__ import annotations
+
+import ast
+import pathlib
+import sys
+from dataclasses import dataclass
+
+# ---------------------------------------------------------------------------
+# Paths (all relative to the repo root, resolved from this file's location)
+# ---------------------------------------------------------------------------
+REPO_ROOT = pathlib.Path(__file__).resolve().parents[3]
+PUBLIC_ROUTES_DIR = REPO_ROOT /
"airflow-core/src/airflow/api_fastapi/core_api/routes/public"
+OUTPUT_RST = REPO_ROOT / "airflow-core/docs/security/api_permissions_ref.rst"
+
+# The global /api/v2 prefix comes from public_router in __init__.py
+API_PREFIX = "/api/v2"
+
+
+# ---------------------------------------------------------------------------
+# Data model
+# ---------------------------------------------------------------------------
+@dataclass(frozen=True, order=True)
+class PermissionEntry:
+ """One HTTP operation's permission requirement."""
+
+ full_path: str # full route path, e.g. /api/v2/dags/{dag_id}
+ http_method: str # GET / POST / PATCH / PUT / DELETE
+ tag: str # OpenAPI tag, e.g. "DAG", "Variable"
+ resource: str # e.g. "DAG", "DAG.RUN", "Variable", "View"
+ required_permission: str # e.g. "GET", "POST", "DELETE", "multi",
"PLUGINS"
+ source_file: str # route file basename for traceability
+
+
+# ---------------------------------------------------------------------------
+# Per-file AST helpers
+# ---------------------------------------------------------------------------
+
+
+def _resolve_string_node(node: ast.expr, module_consts: dict[str, str]) -> str:
+ """
+ Convert an AST expression to a string.
+
+ Handles:
+ - ast.Constant → direct string
+ - ast.BinOp(+) → resolve left and right recursively (string concat)
+ - ast.Name → look up in module_consts if available
+ """
+ if isinstance(node, ast.Constant) and isinstance(node.value, str):
+ return node.value
+ if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
+ left = _resolve_string_node(node.left, module_consts)
+ right = _resolve_string_node(node.right, module_consts)
+ return left + right
+ if isinstance(node, ast.Name) and node.id in module_consts:
+ return module_consts[node.id]
+ # Give up — return an unresolvable marker (will surface in tests)
+ return f"<unresolved:{ast.unparse(node)}>"
+
+
+def _extract_module_string_constants(tree: ast.Module) -> dict[str, str]:
+ """
+ Walk top-level assignments and collect simple string assignments.
+
+ e.g. task_instances_prefix = "/dagRuns/{dag_run_id}/taskInstances"
+ → {"task_instances_prefix": "/dagRuns/{dag_run_id}/taskInstances"}
+ """
+ consts: dict[str, str] = {}
+ for node in tree.body:
+ if (
+ isinstance(node, ast.Assign)
+ and len(node.targets) == 1
+ and isinstance(node.targets[0], ast.Name)
+ and isinstance(node.value, ast.Constant)
+ and isinstance(node.value.value, str)
+ ):
+ consts[node.targets[0].id] = node.value.value
+ return consts
+
+
+def _extract_routers(tree: ast.Module) -> dict[str, str]:
+ """
+ Find all assignments like some_router = AirflowRouter(...) at module level.
+
+ Returns a mapping of router variable name to its prefix.
+ """
+ routers: dict[str, str] = {}
+ for node in tree.body:
+ if not (
+ isinstance(node, ast.Assign)
+ and len(node.targets) == 1
+ and isinstance(node.targets[0], ast.Name)
+ and isinstance(node.value, ast.Call)
+ ):
+ continue
+ call = node.value
+ call_name = (
+ call.func.id
+ if isinstance(call.func, ast.Name)
+ else call.func.attr
+ if isinstance(call.func, ast.Attribute)
+ else ""
+ )
+ if call_name != "AirflowRouter":
+ continue
+
+ target_name = node.targets[0].id
+ prefix = ""
+ for kw in call.keywords:
+ if kw.arg == "prefix" and isinstance(kw.value, ast.Constant) and
isinstance(kw.value.value, str):
+ prefix = kw.value.value
+ elif kw.arg == "dependencies" and isinstance(kw.value, ast.List):
+ for dep_item in kw.value.elts:
+ for subnode in ast.walk(dep_item):
+ if isinstance(subnode, ast.Call):
+ fn_name = _get_requires_access_call_name(subnode)
+ if fn_name is not None:
+ raise ValueError(
+ f"Unsupported extraction semantics:
Router-level permission dependency '{fn_name}' "
+ f"on router '{target_name}' is not
supported by the static permission extractor."
+ )
+ routers[target_name] = prefix
+ return routers
+
+
+def _get_requires_access_call_name(call_node: ast.Call) -> str | None:
+ """Extract the function name from a requires_access_*() call node."""
+ fn = call_node.func
+ if isinstance(fn, ast.Name) and fn.id.startswith("requires_access"):
+ return fn.id
+ if isinstance(fn, ast.Attribute) and fn.attr.startswith("requires_access"):
+ return fn.attr
+ return None
+
+
+def _extract_method_arg(call_node: ast.Call) -> str:
+ """
+ Extract the HTTP method from a requires_access_*(...) call.
+
+ Two calling conventions exist in the codebase:
+ requires_access_dag("GET", ...) ← positional
+ requires_access_dag(method="GET", ...) ← keyword
+
+ Returns the method string (GET/POST/PUT/DELETE) or "multi"
+ for bulk functions that carry no method.
+ """
+ # Positional first arg
+ if call_node.args:
+ first = call_node.args[0]
+ if isinstance(first, ast.Constant) and isinstance(first.value, str):
+ return first.value.upper()
+ return ast.unparse(first).strip("\"'").upper()
+
+ # Keyword method=
+ for kw in call_node.keywords:
+ if kw.arg == "method":
+ val = kw.value
+ if isinstance(val, ast.Constant) and isinstance(val.value, str):
+ return val.value.upper()
+ return ast.unparse(val).strip("\"'").upper()
+
+ # bulk functions: no method arg
+ return "multi"
+
+
+def _extract_entity_arg(call_node: ast.Call) -> str | None:
+ """
+ Extract the access_entity or first positional (for requires_access_view).
+
+ Returns e.g. "TASK_INSTANCE", "PLUGINS", or None.
+ """
+ fn_name = _get_requires_access_call_name(call_node) or ""
+
+ # For requires_access_view the entity IS the first positional arg
+ if fn_name == "requires_access_view":
+ for kw in call_node.keywords:
+ if kw.arg == "access_view":
+ return ast.unparse(kw.value).split(".")[-1] #
AccessView.PLUGINS → "PLUGINS"
+ if call_node.args:
+ return ast.unparse(call_node.args[0]).split(".")[-1]
+ return None
+
+ # For requires_access_dag the entity is the access_entity keyword
+ # or second positional argument
+ if fn_name == "requires_access_dag":
+ for kw in call_node.keywords:
+ if kw.arg == "access_entity":
+ return ast.unparse(kw.value).split(".")[-1] #
DagAccessEntity.RUN → "RUN"
+
+ if len(call_node.args) >= 2:
+ return ast.unparse(call_node.args[1]).split(".")[-1]
+
+ return None
+
+ return None
+
+
+# Map from requires_access_* function name → (resource base name, forced
entity or None)
+_FN_TO_RESOURCE_INFO: dict[str, tuple[str, str | None]] = {
+ "requires_access_dag": ("DAG", None),
+ "requires_access_backfill": ("DAG", "RUN"), # backfill is a DAG.RUN alias
+ "requires_access_dag_run_bulk": ("DAG", "RUN"), # dag_run bulk is a
DAG.RUN alias
+ "requires_access_dag_run_clear_bulk": ("DAG", "RUN"), # dag_run clear
bulk is a DAG.RUN alias
+ "requires_access_event_log": ("DAG", "AUDIT_LOG"), # event log is a
DAG.AUDIT_LOG alias
+ "requires_access_pool": ("Pool", None),
+ "requires_access_pool_bulk": ("Pool", None),
+ "requires_access_connection": ("Connection", None),
+ "requires_access_connection_bulk": ("Connection", None),
+ "requires_access_configuration": ("Configuration", None),
+ "requires_access_variable": ("Variable", None),
+ "requires_access_variable_bulk": ("Variable", None),
+ "requires_access_asset": ("Asset", None),
+ "requires_access_asset_alias": ("AssetAlias", None),
+ "requires_access_view": ("View", None),
+}
+
+
+def _build_resource_label(fn_name: str, entity: str | None) -> str:
+ """Convert fn_name + entity into a human-readable resource label."""
+ if fn_name in _FN_TO_RESOURCE_INFO:
+ base, forced_entity = _FN_TO_RESOURCE_INFO[fn_name]
+ entity_to_use = forced_entity or entity
+ if entity_to_use:
+ return f"{base}.{entity_to_use}"
+ return base
+ return fn_name
+
+
+def _extract_tag_from_decorator(decorator: ast.Call) -> str:
+ """Get the OpenAPI tag from @router.get(tags=["Tag"]) if present."""
+ for kw in decorator.keywords:
+ if kw.arg == "tags" and isinstance(kw.value, ast.List):
+ for elt in kw.value.elts:
+ if isinstance(elt, ast.Constant):
+ return str(elt.value)
+ return "?"
+
+
+# ---------------------------------------------------------------------------
+# Core extraction per file
+# ---------------------------------------------------------------------------
+
+
+def extract_from_file(path: pathlib.Path) -> list[PermissionEntry]:
+ """Parse one route file and return all PermissionEntry objects."""
+ try:
+ source = path.read_text(encoding="utf-8")
+ tree = ast.parse(source)
+ except (OSError, SyntaxError) as exc:
+ print(f"[WARN] Could not parse {path.name}: {exc}", file=sys.stderr)
+ return []
+
+ # Build lookup tables for this file
+ module_consts = _extract_module_string_constants(tree)
+ routers = _extract_routers(tree)
+
+ results: list[PermissionEntry] = []
+
+ for node in ast.walk(tree):
+ if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
+ continue
+
+ for decorator in node.decorator_list:
+ if not isinstance(decorator, ast.Call):
+ continue
+
+ # Determine HTTP method from decorator attribute: @router.GET /
.get / .post …
+ if not isinstance(decorator.func, ast.Attribute):
+ continue
+ http_verb = decorator.func.attr.upper()
+ if http_verb not in {"GET", "POST", "PATCH", "PUT", "DELETE",
"HEAD"}:
+ continue
+
+ # Resolve the route path
+ route_suffix = ""
+ if decorator.args:
+ route_suffix = _resolve_string_node(decorator.args[0],
module_consts)
+
+ # Resolve the prefix based on the router variable used in the
decorator
+ router_prefix = ""
+ if isinstance(decorator.func.value, ast.Name):
+ router_var = decorator.func.value.id
+ router_prefix = routers.get(router_var, "")
+
+ full_path = API_PREFIX + router_prefix + route_suffix
+
+ # Extract tag (for grouping in the RST table)
+ tag = _extract_tag_from_decorator(decorator)
+
+ # Find dependencies=[...] kwarg
+ deps_kwarg = next(
+ (kw for kw in decorator.keywords if kw.arg == "dependencies"),
+ None,
+ )
+
+ has_permission_dependency = False
+ if deps_kwarg is not None and isinstance(deps_kwarg.value,
ast.List):
+ # Walk the dependency list
+ for dep_item in deps_kwarg.value.elts:
+ if not isinstance(dep_item, ast.Call):
+ continue
+ # Must be Depends(...)
+ dep_name = (
+ dep_item.func.id
+ if isinstance(dep_item.func, ast.Name)
+ else getattr(dep_item.func, "attr", "")
+ )
+ if dep_name != "Depends" or not dep_item.args:
+ continue
+
+ inner = dep_item.args[0]
+ if not isinstance(inner, ast.Call):
+ continue
+
+ fn_name = _get_requires_access_call_name(inner)
+ if fn_name is None:
+ continue
+
+ method = _extract_method_arg(inner)
+ entity = _extract_entity_arg(inner)
+ resource = _build_resource_label(fn_name, entity)
+ permission = entity if fn_name == "requires_access_view"
else method
+ if not isinstance(permission, str):
+ raise ValueError(
+ f"Could not resolve required permission for
{fn_name} in {path.name}"
+ )
+
+ results.append(
+ PermissionEntry(
+ http_method=http_verb,
+ full_path=full_path,
+ tag=tag,
+ resource=resource,
+ required_permission=permission,
+ source_file=path.name,
+ )
+ )
+ has_permission_dependency = True
+
+ if not has_permission_dependency:
+ results.append(
+ PermissionEntry(
+ http_method=http_verb,
+ full_path=full_path,
+ tag=tag,
+ resource="Public",
+ required_permission="No Airflow permission required",
+ source_file=path.name,
+ )
+ )
+
+ return results
+
+
+# ---------------------------------------------------------------------------
+# Main extraction entry point
+# ---------------------------------------------------------------------------
+
+
+def extract_all_permissions(routes_dir: pathlib.Path) -> list[PermissionEntry]:
+ """
+ Walk all public route files and return a sorted, deduplicated list
+ of PermissionEntry objects.
+ """
+ all_entries: list[PermissionEntry] = []
+ for route_file in sorted(routes_dir.glob("*.py")):
+ if route_file.name == "__init__.py":
+ continue
+ all_entries.extend(extract_from_file(route_file))
+
+ # Deduplicate (same path+method+resource can appear from multiple deps)
+ seen: set[PermissionEntry] = set()
+ deduped: list[PermissionEntry] = []
+ sorted_entries = sorted(
+ all_entries,
+ key=lambda e: (
+ e.full_path,
+ e.http_method,
+ e.resource,
+ e.required_permission,
+ ),
+ )
+ for entry in sorted_entries:
+ if entry not in seen:
+ seen.add(entry)
+ deduped.append(entry)
+
+ return deduped
+
+
+# ---------------------------------------------------------------------------
+# RST generation
+# ---------------------------------------------------------------------------
+
+RST_HEADER = """\
+ .. 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.
+
+.. THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+ Regenerate with: python scripts/ci/prek/extract_permissions.py
+ Trigger: prek run generate-api-permissions-doc --all-files
+
+API Endpoint Permission Reference
+==================================
+
+This page lists the required permission for every endpoint in the stable
+Airflow REST API (``/api/v2``). It is generated automatically from the
+source code so it stays up to date as endpoints are added or changed.
+
+.. seealso::
+
+ :doc:`/security/api` — for authentication instructions (JWT tokens).
+
+.. note::
+
+ Permissions are enforced by the configured **auth manager**. The
+
:class:`~airflow.api_fastapi.auth.managers.base_auth_manager.BaseAuthManager`
+ interface defines the contract; individual auth manager implementations
+ (e.g. the Simple Auth Manager, or the FAB provider) translate these
+ resource/method tuples into their own role/permission models.
+
+"""
+
+RST_TABLE_HEADER = """\
+.. list-table:: Stable REST API endpoint permissions
+ :header-rows: 1
+ :widths: 7 50 20 13
+
+ * - Method
+ - Endpoint path
+ - Resource
+ - Required permission
+"""
+
+
+def _rst_table_row(entry: PermissionEntry) -> str:
+ return (
+ f" * - ``{entry.http_method}``\n"
+ f" - ``{entry.full_path}``\n"
+ f" - ``{entry.resource}``\n"
+ f" - ``{entry.required_permission}``\n"
+ )
+
+
+def render_rst(entries: list[PermissionEntry]) -> str:
+ """Render the full RST document from the list of PermissionEntry
objects."""
+ rows = "".join(_rst_table_row(e) for e in entries)
+ return RST_HEADER + RST_TABLE_HEADER + rows
+
+
+# ---------------------------------------------------------------------------
+# CLI
+# ---------------------------------------------------------------------------
+
+
+def main(argv: list[str] | None = None) -> int:
+ import argparse
+
+ parser = argparse.ArgumentParser(description="Extract API permissions and
write RST reference doc.")
+ parser.add_argument(
+ "--check",
+ action="store_true",
+ help=(
+ "Check mode: exit 1 if the generated content differs from "
+ f"what is on disk at {OUTPUT_RST}. "
+ "Use in CI to detect stale documentation."
+ ),
+ )
+ parser.add_argument(
+ "--print",
+ dest="print_only",
+ action="store_true",
+ help="Print the generated RST to stdout instead of writing to disk.",
+ )
+ args = parser.parse_args(argv)
+
+ entries = extract_all_permissions(PUBLIC_ROUTES_DIR)
+ content = render_rst(entries)
+
+ if args.print_only:
+ print(content)
+ return 0
+
+ if args.check:
+ if not OUTPUT_RST.exists():
+ print(
+ f"[FAIL] {OUTPUT_RST} does not exist. Run: python
scripts/ci/prek/extract_permissions.py",
+ file=sys.stderr,
+ )
+ return 1
+ existing = OUTPUT_RST.read_text(encoding="utf-8")
+ if existing != content:
+ print(
+ f"[FAIL] {OUTPUT_RST} is stale. Run: python
scripts/ci/prek/extract_permissions.py",
+ file=sys.stderr,
+ )
+ return 1
+ print(f"[OK] {OUTPUT_RST} is up to date.")
+ return 0
+
+ # Write mode (default)
+ OUTPUT_RST.parent.mkdir(parents=True, exist_ok=True)
+ OUTPUT_RST.write_text(content, encoding="utf-8")
+ print(f"[OK] Written {len(entries)} entries to {OUTPUT_RST}")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/tests/ci/prek/test_extract_permissions.py
b/scripts/tests/ci/prek/test_extract_permissions.py
new file mode 100644
index 00000000000..40be37dff0d
--- /dev/null
+++ b/scripts/tests/ci/prek/test_extract_permissions.py
@@ -0,0 +1,832 @@
+# 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.
+"""
+Tests for scripts/ci/prek/extract_permissions.py.
+
+Test strategy:
+ - Unit tests parse small synthetic code strings, never real route files.
+ This makes tests fast, self-contained, and immune to unrelated route
changes.
+ - Integration tests call extract_all_permissions() against the real
+ routes/public directory to guard against regressions when routes change.
+ - No snapshot tests: we assert on invariants (no unresolved markers,
+ no duplicates, count ≥ known_minimum) rather than exact string equality.
+
+Run with (no Airflow env needed — extractor is stdlib-only):
+ uv run --project scripts pytest
scripts/tests/ci/prek/test_extract_permissions.py -xvs
+"""
+
+from __future__ import annotations
+
+import ast
+import sys
+import textwrap
+from pathlib import Path
+
+import pytest
+
+# ---------------------------------------------------------------------------
+# Add scripts/ci/prek to sys.path so we can import the extractor directly.
+# ---------------------------------------------------------------------------
+REPO_ROOT = Path(__file__).resolve().parents[4] # scripts/tests/ci/prek →
repo root
+PREK_DIR = REPO_ROOT / "scripts/ci/prek"
+
+if str(PREK_DIR) not in sys.path:
+ sys.path.insert(0, str(PREK_DIR))
+
+from extract_permissions import ( # noqa: E402
+ _FN_TO_RESOURCE_INFO,
+ PermissionEntry,
+ _build_resource_label,
+ _extract_method_arg,
+ _extract_module_string_constants,
+ _extract_routers,
+ _resolve_string_node,
+ extract_all_permissions,
+ extract_from_file,
+ render_rst,
+)
+
+PUBLIC_ROUTES_DIR = REPO_ROOT /
"airflow-core/src/airflow/api_fastapi/core_api/routes/public"
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+
+def parse_expr(code: str) -> ast.expr:
+ """Parse a single expression string into an AST node."""
+ return ast.parse(code, mode="eval").body
+
+
+def parse_module(code: str) -> ast.Module:
+ """Parse a dedented code block into a module AST."""
+ return ast.parse(textwrap.dedent(code))
+
+
+def _make_route_file(tmp_path: Path, code: str) -> Path:
+ """Write synthetic route code to a temp .py file."""
+ f = tmp_path / "test_route.py"
+ f.write_text(textwrap.dedent(code))
+ return f
+
+
+# ===========================================================================
+# Unit tests: _resolve_string_node
+# ===========================================================================
+
+
+class TestResolveStringNode:
+ def test_plain_string_constant(self):
+ node = parse_expr("'/dags'")
+ assert _resolve_string_node(node, {}) == "/dags"
+
+ def test_name_lookup_in_module_consts(self):
+ node = parse_expr("my_prefix")
+ assert _resolve_string_node(node, {"my_prefix": "/taskInstances"}) ==
"/taskInstances"
+
+ def test_binop_concatenation(self):
+ # Mirrors task_instances.py: task_instances_prefix + "/{task_id}"
+ node = parse_expr("task_instances_prefix + '/{task_id}'")
+ consts = {"task_instances_prefix":
"/dagRuns/{dag_run_id}/taskInstances"}
+ result = _resolve_string_node(node, consts)
+ assert result == "/dagRuns/{dag_run_id}/taskInstances/{task_id}"
+
+ def test_nested_binop(self):
+ # a + b + c → (a + b) + c (left-associative)
+ node = parse_expr("a + b + c")
+ consts = {"a": "/dags", "b": "/{dag_id}", "c": "/runs"}
+ assert _resolve_string_node(node, consts) == "/dags/{dag_id}/runs"
+
+ def test_unresolvable_name_returns_marker(self):
+ node = parse_expr("unknown_var")
+ result = _resolve_string_node(node, {})
+ assert result.startswith("<unresolved:")
+
+ def test_unresolvable_node_type_returns_marker(self):
+ # A function call is not a resolvable string expression
+ node = parse_expr("get_prefix()")
+ result = _resolve_string_node(node, {})
+ assert result.startswith("<unresolved:")
+
+
+# ===========================================================================
+# Unit tests: _extract_module_string_constants
+# ===========================================================================
+
+
+class TestExtractModuleStringConstants:
+ def test_simple_string_assignment(self):
+ tree = parse_module("""
+ prefix = "/dagRuns/{dag_run_id}/taskInstances"
+ """)
+ consts = _extract_module_string_constants(tree)
+ assert consts == {"prefix": "/dagRuns/{dag_run_id}/taskInstances"}
+
+ def test_ignores_non_string_assignments(self):
+ tree = parse_module("""
+ count = 42
+ flag = True
+ name = "/dags"
+ """)
+ consts = _extract_module_string_constants(tree)
+ # Only the string assignment is collected
+ assert consts == {"name": "/dags"}
+ assert "count" not in consts
+ assert "flag" not in consts
+
+ def test_ignores_augmented_assignments(self):
+ tree = parse_module("""
+ prefix = "/base"
+ prefix += "/extra"
+ """)
+ # += is an AugAssign, not Assign — should not be picked up
+ consts = _extract_module_string_constants(tree)
+ assert consts == {"prefix": "/base"}
+
+ def test_multiple_string_constants(self):
+ tree = parse_module("""
+ a_prefix = "/a"
+ b_prefix = "/b"
+ """)
+ consts = _extract_module_string_constants(tree)
+ assert consts == {"a_prefix": "/a", "b_prefix": "/b"}
+
+ def test_empty_module(self):
+ tree = parse_module("")
+ assert _extract_module_string_constants(tree) == {}
+
+
+# ===========================================================================
+# Unit tests: _extract_router_prefix
+# ===========================================================================
+
+
+class TestExtractRouters:
+ def test_extracts_prefixes_from_airflow_routers(self):
+ tree = parse_module("""
+ router_a = AirflowRouter(tags=["DAG"], prefix="/first")
+ router_b = AirflowRouter(prefix="/second")
+ router_c = AirflowRouter()
+ """)
+ assert _extract_routers(tree) == {
+ "router_a": "/first",
+ "router_b": "/second",
+ "router_c": "",
+ }
+
+ def test_returns_empty_dict_when_no_routers(self):
+ tree = parse_module("x = 1")
+ assert _extract_routers(tree) == {}
+
+ def test_prefix_with_path_parameter(self):
+ tree = parse_module("""
+ ti_router = AirflowRouter(tags=["Task Instance"],
prefix="/dags/{dag_id}")
+ """)
+ assert _extract_routers(tree) == {"ti_router": "/dags/{dag_id}"}
+
+ def test_router_level_permission_dependency_raises_value_error(self):
+ tree = parse_module("""
+ from fastapi import Depends
+ from airflow.api_fastapi.core_api.security import
requires_access_dag
+
+ dags_router = AirflowRouter(
+ tags=["DAG"],
+ prefix="/dags",
+ dependencies=[Depends(requires_access_dag("GET"))],
+ )
+ """)
+ with pytest.raises(
+ ValueError, match="Unsupported extraction semantics: Router-level
permission dependency"
+ ):
+ _extract_routers(tree)
+
+
+# ===========================================================================
+# Unit tests: _extract_method_arg
+# ===========================================================================
+
+
+class TestExtractMethodArg:
+ def _call(self, code: str) -> ast.Call:
+ return ast.parse(code, mode="eval").body # type: ignore[return-value]
+
+ def test_keyword_method(self):
+ call = self._call("requires_access_dag(method='GET',
access_entity=DagAccessEntity.RUN)")
+ assert _extract_method_arg(call) == "GET"
+
+ def test_positional_method(self):
+ # e.g. requires_access_variable('DELETE')
+ call = self._call("requires_access_variable('DELETE')")
+ assert _extract_method_arg(call) == "DELETE"
+
+ def test_positional_method_is_uppercased(self):
+ call = self._call("requires_access_pool('get')")
+ assert _extract_method_arg(call) == "GET"
+
+ def test_bulk_function_returns_multi(self):
+ # requires_access_pool_bulk() has no method argument
+ call = self._call("requires_access_pool_bulk()")
+ assert _extract_method_arg(call) == "multi"
+
+ def test_keyword_method_is_uppercased(self):
+ call = self._call("requires_access_dag(method='put')")
+ assert _extract_method_arg(call) == "PUT"
+
+ def test_post_keyword(self):
+ call = self._call("requires_access_connection(method='POST')")
+ assert _extract_method_arg(call) == "POST"
+
+
+# ===========================================================================
+# Unit tests: _build_resource_label
+# ===========================================================================
+
+
+class TestBuildResourceLabel:
+ def test_simple_resource_no_entity(self):
+ assert _build_resource_label("requires_access_pool", None) == "Pool"
+
+ def test_dag_with_entity(self):
+ assert _build_resource_label("requires_access_dag", "RUN") == "DAG.RUN"
+
+ def test_dag_with_task_instance_entity(self):
+ assert _build_resource_label("requires_access_dag", "TASK_INSTANCE")
== "DAG.TASK_INSTANCE"
+
+ def test_alias_backfill_forces_run_entity(self):
+ # requires_access_backfill → DAG.RUN regardless of entity arg
+ assert _build_resource_label("requires_access_backfill", None) ==
"DAG.RUN"
+
+ def test_alias_dag_run_bulk_forces_run_entity(self):
+ assert _build_resource_label("requires_access_dag_run_bulk", None) ==
"DAG.RUN"
+
+ def test_alias_event_log_forces_audit_log(self):
+ assert _build_resource_label("requires_access_event_log", None) ==
"DAG.AUDIT_LOG"
+
+ def test_view_with_no_entity_returns_base(self):
+ assert _build_resource_label("requires_access_view", None) == "View"
+
+ def test_view_with_entity(self):
+ assert _build_resource_label("requires_access_view", "PLUGINS") ==
"View.PLUGINS"
+
+ def test_unknown_function_falls_back_to_fn_name(self):
+ # If a new requires_access_* is added but not yet in the map, the
+ # function name is used. Tests will catch it via the coverage test.
+ assert _build_resource_label("requires_access_new_thing", None) ==
"requires_access_new_thing"
+
+
+# ===========================================================================
+# Unit tests: extract_from_file (synthetic route files)
+# ===========================================================================
+
+
+class TestExtractFromFile:
+ def test_basic_get_with_keyword_method(self, tmp_path):
+ f = _make_route_file(
+ tmp_path,
+ """
+ from fastapi import Depends
+ dags_router = AirflowRouter(tags=["DAG"], prefix="/dags")
+
+ @dags_router.get(
+ "/{dag_id}",
+ dependencies=[Depends(requires_access_dag(method="GET"))],
+ )
+ def get_dag(dag_id: str): ...
+ """,
+ )
+ entries = extract_from_file(f)
+ assert len(entries) == 1
+ e = entries[0]
+ assert e.http_method == "GET"
+ assert e.full_path == "/api/v2/dags/{dag_id}"
+ assert e.resource == "DAG"
+ assert e.required_permission == "GET"
+
+ def test_multiple_routers_in_same_file(self, tmp_path):
+ f = _make_route_file(
+ tmp_path,
+ """
+ from fastapi import Depends
+ dag_run_router = AirflowRouter(prefix="/dags/{dag_id}/dagRuns")
+ dag_run_at_dag_router = AirflowRouter(prefix="/dags/{dag_id}")
+
+ @dag_run_router.post(
+ "/clear",
+ dependencies=[Depends(requires_access_dag(method="POST",
access_entity=DagAccessEntity.RUN))],
+ )
+ def clear_dag_runs(dag_id: str): ...
+
+ @dag_run_at_dag_router.post(
+ "/clearDagRuns",
+ dependencies=[Depends(requires_access_dag(method="POST",
access_entity=DagAccessEntity.RUN))],
+ )
+ def clear_dag_runs_at_dag(dag_id: str): ...
+ """,
+ )
+ entries = extract_from_file(f)
+ assert len(entries) == 2
+ # Sort by full_path to be deterministic in assertions
+ entries_sorted = sorted(entries, key=lambda e: e.full_path)
+
+ # /api/v2/dags/{dag_id}/clearDagRuns (from dag_run_at_dag_router)
+ assert entries_sorted[0].full_path ==
"/api/v2/dags/{dag_id}/clearDagRuns"
+ assert entries_sorted[0].http_method == "POST"
+
+ # /api/v2/dags/{dag_id}/dagRuns/clear (from dag_run_router)
+ assert entries_sorted[1].full_path ==
"/api/v2/dags/{dag_id}/dagRuns/clear"
+ assert entries_sorted[1].http_method == "POST"
+
+ def test_positional_method_arg(self, tmp_path):
+ f = _make_route_file(
+ tmp_path,
+ """
+ from fastapi import Depends
+ variables_router = AirflowRouter(prefix="/variables")
+
+ @variables_router.delete(
+ "/{variable_key:path}",
+ dependencies=[Depends(requires_access_variable("DELETE"))],
+ )
+ def delete_variable(variable_key: str): ...
+ """,
+ )
+ entries = extract_from_file(f)
+ assert len(entries) == 1
+ assert entries[0].http_method == "DELETE"
+ assert entries[0].required_permission == "DELETE"
+ assert entries[0].resource == "Variable"
+
+ def test_dag_access_entity_positional(self, tmp_path):
+ """The second positional arg to requires_access_dag is the
access_entity."""
+ f = _make_route_file(
+ tmp_path,
+ """
+ from fastapi import Depends
+ dag_router = AirflowRouter(tags=["DAG"], prefix="/dags/{dag_id}")
+
+ @dag_router.get(
+ "/taskLogs",
+ dependencies=[Depends(requires_access_dag("GET",
DagAccessEntity.TASK_LOGS))],
+ )
+ def get_task_logs(dag_id: str): ...
+ """,
+ )
+ entries = extract_from_file(f)
+ assert len(entries) == 1
+ e = entries[0]
+ assert e.resource == "DAG.TASK_LOGS"
+ assert e.required_permission == "GET"
+
+ def test_binop_path_resolved(self, tmp_path):
+ # Mirrors the task_instances.py pattern exactly
+ f = _make_route_file(
+ tmp_path,
+ """
+ from fastapi import Depends
+ task_instances_router = AirflowRouter(prefix="/dags/{dag_id}")
+ task_instances_prefix = "/dagRuns/{dag_run_id}/taskInstances"
+
+ @task_instances_router.get(
+ task_instances_prefix + "/{task_id}",
+ dependencies=[Depends(requires_access_dag(method="GET",
access_entity=DagAccessEntity.TASK_INSTANCE))],
+ )
+ def get_task_instance(dag_id: str, dag_run_id: str, task_id: str):
...
+ """,
+ )
+ entries = extract_from_file(f)
+ assert len(entries) == 1
+ e = entries[0]
+ assert e.full_path ==
"/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}"
+ assert e.resource == "DAG.TASK_INSTANCE"
+ assert e.http_method == "GET"
+
+ def test_bulk_function_no_method_arg(self, tmp_path):
+ f = _make_route_file(
+ tmp_path,
+ """
+ from fastapi import Depends
+ pools_router = AirflowRouter(prefix="/pools")
+
+ @pools_router.patch(
+ "",
+ dependencies=[Depends(requires_access_pool_bulk())],
+ )
+ def bulk_pools(): ...
+ """,
+ )
+ entries = extract_from_file(f)
+ assert len(entries) == 1
+ assert entries[0].required_permission == "multi"
+ assert entries[0].resource == "Pool"
+
+ def test_view_access_positional(self, tmp_path):
+ f = _make_route_file(
+ tmp_path,
+ """
+ from fastapi import Depends
+ plugins_router = AirflowRouter(tags=["Plugin"], prefix="/plugins")
+
+ @plugins_router.get(
+ "",
+
dependencies=[Depends(requires_access_view(AccessView.PLUGINS))],
+ )
+ def get_plugins(): ...
+ """,
+ )
+ entries = extract_from_file(f)
+ assert len(entries) == 1
+ e = entries[0]
+ assert e.resource == "View.PLUGINS"
+ assert e.required_permission == "PLUGINS"
+
+ def test_no_dependencies_kwarg_extracted_as_public(self, tmp_path):
+ f = _make_route_file(
+ tmp_path,
+ """
+ from fastapi import Depends
+ router = AirflowRouter(prefix="/version")
+
+ @router.get("")
+ def get_version(): ...
+ """,
+ )
+ entries = extract_from_file(f)
+ assert len(entries) == 1
+ e = entries[0]
+ assert e.http_method == "GET"
+ assert e.full_path == "/api/v2/version"
+ assert e.resource == "Public"
+ assert e.required_permission == "No Airflow permission required"
+
+ def test_only_unrelated_dependencies_extracted_as_public(self, tmp_path):
+ f = _make_route_file(
+ tmp_path,
+ """
+ from fastapi import Depends
+ router = AirflowRouter(prefix="/version")
+
+ @router.get("", dependencies=[Depends(action_logging())])
+ def get_version(): ...
+ """,
+ )
+ entries = extract_from_file(f)
+ assert len(entries) == 1
+ e = entries[0]
+ assert e.http_method == "GET"
+ assert e.full_path == "/api/v2/version"
+ assert e.resource == "Public"
+ assert e.required_permission == "No Airflow permission required"
+
+ def test_multiple_deps_on_same_route_produces_multiple_entries(self,
tmp_path):
+ f = _make_route_file(
+ tmp_path,
+ """
+ from fastapi import Depends
+ dag_run_router = AirflowRouter(prefix="/dags/{dag_id}/dagRuns")
+
+ @dag_run_router.get(
+ "/{dag_run_id}/upstreamAssetEvents",
+ dependencies=[
+ Depends(requires_access_asset(method="GET")),
+ Depends(requires_access_dag(method="GET",
access_entity=DagAccessEntity.RUN)),
+ ],
+ )
+ def get_upstream_events(): ...
+ """,
+ )
+ entries = extract_from_file(f)
+ assert len(entries) == 2
+ resources = {e.resource for e in entries}
+ assert "Asset" in resources
+ assert "DAG.RUN" in resources
+
+ def test_non_requires_access_dep_is_ignored(self, tmp_path):
+ # action_logging() is a dep that should not produce a permission entry
+ f = _make_route_file(
+ tmp_path,
+ """
+ from fastapi import Depends
+ router = AirflowRouter(prefix="/dags")
+
+ @router.post(
+ "",
+ dependencies=[Depends(action_logging()),
Depends(requires_access_dag(method="POST"))],
+ )
+ def post_dag(): ...
+ """,
+ )
+ entries = extract_from_file(f)
+ assert len(entries) == 1
+ assert entries[0].resource == "DAG"
+
+ def test_syntax_error_returns_empty_list(self, tmp_path, capsys):
+ f = tmp_path / "broken.py"
+ f.write_text("def broken(:\n")
+ entries = extract_from_file(f)
+ assert entries == []
+ # Should print a warning, not raise
+ captured = capsys.readouterr()
+ assert "WARN" in captured.err
+
+ def test_source_file_is_basename(self, tmp_path):
+ f = _make_route_file(
+ tmp_path,
+ """
+ router = AirflowRouter(prefix="/pools")
+
+ @router.get("",
dependencies=[Depends(requires_access_pool(method="GET"))])
+ def get_pools(): ...
+ """,
+ )
+ entries = extract_from_file(f)
+ assert entries[0].source_file == "test_route.py"
+
+
+# ===========================================================================
+# Unit tests: _FN_TO_RESOURCE coverage invariant
+# ===========================================================================
+
+
+class TestResourceMapCoverage:
+ """Guard against _FN_TO_RESOURCE_INFO going stale as new requires_access_*
functions are added."""
+
+ def _get_all_imported_security_fns(self) -> set[str]:
+ """
+ Find all requires_access_* names imported in public route files.
+ This is the ground truth of what the extractor must know about.
+ """
+ imported: set[str] = set()
+ for route_file in PUBLIC_ROUTES_DIR.glob("*.py"):
+ if route_file.name == "__init__.py":
+ continue
+ try:
+ tree = ast.parse(route_file.read_text())
+ except SyntaxError:
+ continue
+ for node in ast.walk(tree):
+ if isinstance(node, ast.ImportFrom) and node.module and
"security" in node.module:
+ for alias in node.names:
+ if alias.name.startswith("requires_access"):
+ imported.add(alias.name)
+ return imported
+
+ def test_all_imported_functions_are_in_resource_map(self):
+ """
+ If a new requires_access_* function is added to security.py and used in
+ a route, it must also be added to _FN_TO_RESOURCE_INFO in the
extractor.
+
+ Failure here means: a new endpoint has no documented permission.
+ """
+ imported = self._get_all_imported_security_fns()
+ unmapped = imported - set(_FN_TO_RESOURCE_INFO.keys())
+ assert not unmapped, (
+ "These requires_access_* functions are used in route files but are
not "
+ "in _FN_TO_RESOURCE_INFO in extract_permissions.py:\n"
+ + "\n".join(f" - {fn}" for fn in sorted(unmapped))
+ )
+
+ def test_no_dead_entries_in_resource_map(self):
+ """
+ Every entry in _FN_TO_RESOURCE_INFO should correspond to a function
actually
+ used in route files. Dead entries suggest the function was removed or
renamed.
+
+ This is a WARNING-level test: it identifies map entries that should be
cleaned up.
+ """
+ imported = self._get_all_imported_security_fns()
+ dead = set(_FN_TO_RESOURCE_INFO.keys()) - imported
+ assert not dead, (
+ "These entries in _FN_TO_RESOURCE_INFO are not used by any route
file "
+ "and should be removed:\n" + "\n".join(f" - {fn}" for fn in
sorted(dead))
+ )
+
+
+# ===========================================================================
+# Integration tests: extract_all_permissions against real routes
+# ===========================================================================
+
+
+class TestExtractAllPermissions:
+ """
+ Integration tests against the real route files.
+ Uses invariants, not snapshots, so they survive unrelated route additions.
+ """
+
+ @pytest.fixture(scope="class")
+ def all_entries(self) -> list[PermissionEntry]:
+ return extract_all_permissions(PUBLIC_ROUTES_DIR)
+
+ def test_extracts_non_empty_result(self, all_entries):
+ assert len(all_entries) > 0
+
+ def test_minimum_known_entry_count(self, all_entries):
+ """
+ Guard against the extractor silently returning fewer results.
+ The exact number will grow; 100 is a floor well below current 123.
+ """
+ assert len(all_entries) >= 100, f"Expected ≥100 entries, got
{len(all_entries)}"
+
+ def test_output_is_sorted(self, all_entries):
+ expected = sorted(
+ all_entries,
+ key=lambda e: (
+ e.full_path,
+ e.http_method,
+ e.resource,
+ e.required_permission,
+ ),
+ )
+ assert all_entries == expected
+
+ def test_public_endpoints_coverage(self, all_entries):
+ """Verify that known public endpoints are extracted as Public."""
+ public_paths = {
+ "/api/v2/monitor/health": "GET",
+ "/api/v2/version": "GET",
+ "/api/v2/auth/login": "GET",
+ "/api/v2/auth/logout": "GET",
+ }
+ for path, method in public_paths.items():
+ matches = [e for e in all_entries if e.full_path == path and
e.http_method == method]
+ assert len(matches) == 1, f"Expected exactly one match for public
endpoint {method} {path}"
+ e = matches[0]
+ assert e.resource == "Public"
+ assert e.required_permission == "No Airflow permission required"
+
+ def test_no_duplicate_entries(self, all_entries):
+ seen: set[PermissionEntry] = set()
+ for e in all_entries:
+ assert e not in seen, f"Duplicate entry: {e}"
+ seen.add(e)
+
+ def test_no_unresolved_path_markers(self, all_entries):
+ unresolved = [e for e in all_entries if "<unresolved:" in e.full_path]
+ assert not unresolved, "Some route paths could not be resolved
statically:\n" + "\n".join(
+ f" {e.source_file}: {e.full_path}" for e in unresolved
+ )
+
+ def test_all_paths_start_with_api_v2(self, all_entries):
+ bad = [e for e in all_entries if not e.full_path.startswith("/api/v2")]
+ assert not bad, f"Paths not starting with /api/v2: {[e.full_path for e
in bad]}"
+
+ def test_all_http_methods_are_valid(self, all_entries):
+ valid = {"GET", "POST", "PATCH", "PUT", "DELETE", "HEAD"}
+ bad = [e for e in all_entries if e.http_method not in valid]
+ assert not bad, f"Invalid HTTP methods: {[(e.http_method, e.full_path)
for e in bad]}"
+
+ def test_all_resources_are_mapped(self, all_entries):
+ """
+ No resource should fall back to a raw function name.
+ If _FN_TO_RESOURCE_INFO is missing an entry, the resource will equal
+ the function name (e.g. "requires_access_new_thing").
+ """
+ # A resource that matches a raw requires_access_* name is unmapped
+ raw_fn_resources = [e for e in all_entries if
e.resource.startswith("requires_access")]
+ assert not raw_fn_resources, (
+ "Some entries have unmapped resources (function name used as
fallback):\n"
+ + "\n".join(f" {e.source_file}: {e.resource}" for e in
raw_fn_resources)
+ )
+
+ # --- Specific known endpoints ---
+
+ def test_dags_get_list_permission(self, all_entries):
+ matches = [e for e in all_entries if e.full_path == "/api/v2/dags" and
e.http_method == "GET"]
+ assert len(matches) >= 1
+ assert any(e.resource == "DAG" and e.required_permission == "GET" for
e in matches)
+
+ def test_variable_delete_permission(self, all_entries):
+ matches = [
+ e for e in all_entries if "/api/v2/variables/" in e.full_path and
e.http_method == "DELETE"
+ ]
+ assert len(matches) >= 1
+ assert all(e.resource == "Variable" and e.required_permission ==
"DELETE" for e in matches)
+
+ def test_task_instance_path_resolved(self, all_entries):
+ """The BinOp path in task_instances.py must be fully resolved."""
+ ti_entries = [e for e in all_entries if "/taskInstances/" in
e.full_path]
+ assert len(ti_entries) > 0
+ for e in ti_entries:
+ assert "<unresolved:" not in e.full_path
+
+ def test_dag_run_bulk_mapped_to_dag_run(self, all_entries):
+ bulk = [e for e in all_entries if e.source_file == "dag_run.py" and
e.required_permission == "multi"]
+ assert len(bulk) >= 1
+ assert all(e.resource == "DAG.RUN" for e in bulk)
+
+ def test_view_permissions_use_entity_as_permission(self, all_entries):
+ view_entries = [e for e in all_entries if
e.resource.startswith("View.")]
+ assert len(view_entries) > 0
+ for e in view_entries:
+ # For view permissions, required_permission == the view name (e.g.
"PLUGINS")
+ assert e.required_permission == e.resource.split(".")[-1]
+
+ def test_event_log_mapped_to_dag_audit_log(self, all_entries):
+ el_entries = [e for e in all_entries if e.source_file ==
"event_logs.py"]
+ assert len(el_entries) > 0
+ assert all(e.resource == "DAG.AUDIT_LOG" for e in el_entries)
+
+ def test_backfill_mapped_to_dag_run(self, all_entries):
+ bf_entries = [e for e in all_entries if e.source_file ==
"backfills.py"]
+ assert len(bf_entries) > 0
+ assert all(e.resource == "DAG.RUN" for e in bf_entries)
+
+ def test_clear_dag_runs_endpoint_prefix(self, all_entries):
+ """Verify that the clearDagRuns endpoint resolves to the correct path
prefix."""
+ matches = [e for e in all_entries if e.full_path ==
"/api/v2/dags/{dag_id}/clearDagRuns"]
+ assert len(matches) == 1
+ e = matches[0]
+ assert e.http_method == "POST"
+ assert e.resource == "DAG.RUN"
+ assert e.required_permission == "multi"
+
+ # Also verify that other dag runs endpoints still resolve with the
longer prefix /api/v2/dags/{dag_id}/dagRuns
+ dag_runs_list = [
+ e
+ for e in all_entries
+ if e.full_path == "/api/v2/dags/{dag_id}/dagRuns" and
e.http_method == "GET"
+ ]
+ assert len(dag_runs_list) >= 1
+
+
+# ===========================================================================
+# Integration tests: render_rst
+# ===========================================================================
+
+
+class TestRenderRst:
+ @pytest.fixture(scope="class")
+ def rst_content(self) -> str:
+ entries = extract_all_permissions(PUBLIC_ROUTES_DIR)
+ return render_rst(entries)
+
+ def test_rst_contains_auto_generated_marker(self, rst_content):
+ assert "AUTO-GENERATED" in rst_content
+
+ def test_rst_contains_list_table_directive(self, rst_content):
+ assert ".. list-table::" in rst_content
+
+ def test_rst_contains_api_v2_paths(self, rst_content):
+ assert "/api/v2/" in rst_content
+
+ def test_rst_contains_no_unresolved_markers(self, rst_content):
+ assert "<unresolved:" not in rst_content
+
+ def test_rst_output_is_deterministic(self):
+ """Two successive calls must produce identical output."""
+ entries1 = extract_all_permissions(PUBLIC_ROUTES_DIR)
+ entries2 = extract_all_permissions(PUBLIC_ROUTES_DIR)
+ assert render_rst(entries1) == render_rst(entries2)
+
+
+# ===========================================================================
+# Integration test: --check mode (CI guard)
+# ===========================================================================
+
+
+class TestCheckMode:
+ def test_check_mode_passes_when_rst_is_up_to_date(self, tmp_path,
monkeypatch):
+ """The --check CLI flag should return 0 when the on-disk RST
matches."""
+ import extract_permissions as ep
+
+ entries = extract_all_permissions(PUBLIC_ROUTES_DIR)
+ content = render_rst(entries)
+
+ rst_file = tmp_path / "api_permissions_ref.rst"
+ rst_file.write_text(content)
+
+ monkeypatch.setattr(ep, "OUTPUT_RST", rst_file)
+ result = ep.main(["--check"])
+ assert result == 0
+
+ def test_check_mode_fails_when_rst_is_stale(self, tmp_path, monkeypatch):
+ """The --check CLI flag should return 1 when the on-disk RST is
stale."""
+ import extract_permissions as ep
+
+ rst_file = tmp_path / "api_permissions_ref.rst"
+ rst_file.write_text("# stale content")
+
+ monkeypatch.setattr(ep, "OUTPUT_RST", rst_file)
+ result = ep.main(["--check"])
+ assert result == 1
+
+ def test_check_mode_fails_when_rst_missing(self, tmp_path, monkeypatch):
+ """The --check CLI flag should return 1 when the RST file doesn't
exist."""
+ import extract_permissions as ep
+
+ monkeypatch.setattr(ep, "OUTPUT_RST", tmp_path / "does_not_exist.rst")
+ result = ep.main(["--check"])
+ assert result == 1