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

Reply via email to