From cf4fa4ba317d05cd1c2a84df8db1904fd7a2c660 Mon Sep 17 00:00:00 2001 From: Durgaprasad M L Date: Wed, 27 May 2026 21:29:01 +0530 Subject: [PATCH 1/6] Generate stable REST API permission reference docs automatically --- .../docs/security/api_permissions_ref.rst | 540 ++++++++++++++ scripts/ci/prek/extract_permissions.py | 489 ++++++++++++ .../tests/ci/prek/test_extract_permissions.py | 704 ++++++++++++++++++ 3 files changed, 1733 insertions(+) create mode 100644 airflow-core/docs/security/api_permissions_ref.rst create mode 100644 scripts/ci/prek/extract_permissions.py create mode 100644 scripts/tests/ci/prek/test_extract_permissions.py 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 0000000000000..e1c3caf0c90e8 --- /dev/null +++ b/airflow-core/docs/security/api_permissions_ref.rst @@ -0,0 +1,540 @@ + .. 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 + * - ``DELETE`` + - ``/api/v2/assets/{asset_id}/queuedEvents`` + - ``Asset`` + - ``DELETE`` + * - ``DELETE`` + - ``/api/v2/assets/{asset_id}/queuedEvents`` + - ``DAG`` + - ``GET`` + * - ``DELETE`` + - ``/api/v2/assets/{asset_id}/states`` + - ``Asset`` + - ``DELETE`` + * - ``DELETE`` + - ``/api/v2/assets/{asset_id}/states/{key:path}`` + - ``Asset`` + - ``DELETE`` + * - ``DELETE`` + - ``/api/v2/connections/{connection_id}`` + - ``Connection`` + - ``DELETE`` + * - ``DELETE`` + - ``/api/v2/dags/{dag_id}`` + - ``DAG`` + - ``DELETE`` + * - ``DELETE`` + - ``/api/v2/dags/{dag_id}/assets/queuedEvents`` + - ``Asset`` + - ``DELETE`` + * - ``DELETE`` + - ``/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`` + * - ``DELETE`` + - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}`` + - ``DAG.RUN`` + - ``DELETE`` + * - ``DELETE`` + - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}`` + - ``DAG.TASK_INSTANCE`` + - ``DELETE`` + * - ``DELETE`` + - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/states`` + - ``DAG.TASK_INSTANCE`` + - ``DELETE`` + * - ``DELETE`` + - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/states/{key:path}`` + - ``DAG.TASK_INSTANCE`` + - ``DELETE`` + * - ``DELETE`` + - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/xcomEntries/{xcom_key:path}`` + - ``DAG.XCOM`` + - ``DELETE`` + * - ``DELETE`` + - ``/api/v2/pools/{pool_name:path}`` + - ``Pool`` + - ``DELETE`` + * - ``DELETE`` + - ``/api/v2/variables/{variable_key:path}`` + - ``Variable`` + - ``DELETE`` + * - ``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`` + * - ``GET`` + - ``/api/v2/assets/{asset_id}`` + - ``Asset`` + - ``GET`` + * - ``GET`` + - ``/api/v2/assets/{asset_id}`` + - ``AssetAlias`` + - ``GET`` + * - ``GET`` + - ``/api/v2/assets/{asset_id}/queuedEvents`` + - ``Asset`` + - ``GET`` + * - ``GET`` + - ``/api/v2/assets/{asset_id}/states`` + - ``Asset`` + - ``GET`` + * - ``GET`` + - ``/api/v2/assets/{asset_id}/states/{key:path}`` + - ``Asset`` + - ``GET`` + * - ``GET`` + - ``/api/v2/backfills`` + - ``DAG.RUN`` + - ``GET`` + * - ``GET`` + - ``/api/v2/config`` + - ``Configuration`` + - ``GET`` + * - ``GET`` + - ``/api/v2/config/section/{section}/option/{option}`` + - ``Configuration`` + - ``GET`` + * - ``GET`` + - ``/api/v2/connections`` + - ``Connection`` + - ``GET`` + * - ``GET`` + - ``/api/v2/connections/{connection_id}`` + - ``Connection`` + - ``GET`` + * - ``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`` + * - ``GET`` + - ``/api/v2/dags/{dag_id}`` + - ``DAG`` + - ``GET`` + * - ``GET`` + - ``/api/v2/dags/{dag_id}/assets/queuedEvents`` + - ``Asset`` + - ``GET`` + * - ``GET`` + - ``/api/v2/dags/{dag_id}/assets/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`` + * - ``GET`` + - ``/api/v2/dags/{dag_id}/dagRuns`` + - ``DAG.RUN`` + - ``GET`` + * - ``GET`` + - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}`` + - ``DAG.RUN`` + - ``GET`` + * - ``GET`` + - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/hitlDetails`` + - ``DAG.HITL_DETAIL`` + - ``GET`` + * - ``GET`` + - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances`` + - ``DAG.TASK_INSTANCE`` + - ``GET`` + * - ``GET`` + - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}`` + - ``DAG.TASK_INSTANCE`` + - ``GET`` + * - ``GET`` + - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/dependencies`` + - ``DAG.TASK_INSTANCE`` + - ``GET`` + * - ``GET`` + - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/externalLogUrl/{try_number}`` + - ``DAG`` + - ``GET`` + * - ``GET`` + - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/links`` + - ``DAG`` + - ``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`` + - ``GET`` + * - ``GET`` + - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/states`` + - ``DAG.TASK_INSTANCE`` + - ``GET`` + * - ``GET`` + - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/states/{key:path}`` + - ``DAG.TASK_INSTANCE`` + - ``GET`` + * - ``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`` + * - ``GET`` + - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/xcomEntries/{xcom_key:path}`` + - ``DAG.XCOM`` + - ``GET`` + * - ``GET`` + - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/{map_index}`` + - ``DAG.TASK_INSTANCE`` + - ``GET`` + * - ``GET`` + - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/{map_index}/dependencies`` + - ``DAG.TASK_INSTANCE`` + - ``GET`` + * - ``GET`` + - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/{map_index}/hitlDetails`` + - ``DAG.HITL_DETAIL`` + - ``GET`` + * - ``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`` + * - ``GET`` + - ``/api/v2/dags/{dag_id}/tasks`` + - ``DAG.TASK`` + - ``GET`` + * - ``GET`` + - ``/api/v2/dags/{dag_id}/tasks/{task_id}`` + - ``DAG.TASK`` + - ``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/plugins`` + - ``View.PLUGINS`` + - ``PLUGINS`` + * - ``GET`` + - ``/api/v2/plugins/importErrors`` + - ``View.PLUGINS`` + - ``PLUGINS`` + * - ``GET`` + - ``/api/v2/pools`` + - ``Pool`` + - ``GET`` + * - ``GET`` + - ``/api/v2/pools/{pool_name:path}`` + - ``Pool`` + - ``GET`` + * - ``GET`` + - ``/api/v2/providers`` + - ``View.PROVIDERS`` + - ``PROVIDERS`` + * - ``GET`` + - ``/api/v2/variables`` + - ``Variable`` + - ``GET`` + * - ``GET`` + - ``/api/v2/variables/{variable_key:path}`` + - ``Variable`` + - ``GET`` + * - ``PATCH`` + - ``/api/v2/connections`` + - ``Connection`` + - ``multi`` + * - ``PATCH`` + - ``/api/v2/connections/{connection_id}`` + - ``Connection`` + - ``PUT`` + * - ``PATCH`` + - ``/api/v2/dags`` + - ``DAG`` + - ``PUT`` + * - ``PATCH`` + - ``/api/v2/dags/{dag_id}`` + - ``DAG`` + - ``PUT`` + * - ``PATCH`` + - ``/api/v2/dags/{dag_id}/dagRuns`` + - ``DAG.RUN`` + - ``multi`` + * - ``PATCH`` + - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}`` + - ``DAG.RUN`` + - ``PUT`` + * - ``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`` + * - ``PATCH`` + - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances`` + - ``DAG.TASK_INSTANCE`` + - ``PUT`` + * - ``PATCH`` + - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}`` + - ``DAG.TASK_INSTANCE`` + - ``PUT`` + * - ``PATCH`` + - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/dry_run`` + - ``DAG.TASK_INSTANCE`` + - ``PUT`` + * - ``PATCH`` + - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/xcomEntries/{xcom_key:path}`` + - ``DAG.XCOM`` + - ``PUT`` + * - ``PATCH`` + - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/{map_index}`` + - ``DAG.TASK_INSTANCE`` + - ``PUT`` + * - ``PATCH`` + - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/{map_index}/dry_run`` + - ``DAG.TASK_INSTANCE`` + - ``PUT`` + * - ``PATCH`` + - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/{map_index}/hitlDetails`` + - ``DAG.HITL_DETAIL`` + - ``PUT`` + * - ``PATCH`` + - ``/api/v2/pools`` + - ``Pool`` + - ``multi`` + * - ``PATCH`` + - ``/api/v2/pools/{pool_name:path}`` + - ``Pool`` + - ``PUT`` + * - ``PATCH`` + - ``/api/v2/variables`` + - ``Variable`` + - ``multi`` + * - ``PATCH`` + - ``/api/v2/variables/{variable_key:path}`` + - ``Variable`` + - ``PUT`` + * - ``POST`` + - ``/api/v2/assets/events`` + - ``Asset`` + - ``POST`` + * - ``POST`` + - ``/api/v2/assets/{asset_id}/materialize`` + - ``Asset`` + - ``POST`` + * - ``POST`` + - ``/api/v2/backfills`` + - ``DAG.RUN`` + - ``POST`` + * - ``POST`` + - ``/api/v2/connections`` + - ``Connection`` + - ``POST`` + * - ``POST`` + - ``/api/v2/connections/defaults`` + - ``Connection`` + - ``POST`` + * - ``POST`` + - ``/api/v2/connections/test`` + - ``Connection`` + - ``POST`` + * - ``POST`` + - ``/api/v2/dags/{dag_id}/clearTaskInstances`` + - ``DAG.TASK_INSTANCE`` + - ``PUT`` + * - ``POST`` + - ``/api/v2/dags/{dag_id}/dagRuns`` + - ``DAG.RUN`` + - ``POST`` + * - ``POST`` + - ``/api/v2/dags/{dag_id}/dagRuns/list`` + - ``DAG.RUN`` + - ``GET`` + * - ``POST`` + - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/clear`` + - ``DAG.RUN`` + - ``PUT`` + * - ``POST`` + - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/list`` + - ``DAG.TASK_INSTANCE`` + - ``GET`` + * - ``POST`` + - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/xcomEntries`` + - ``DAG.XCOM`` + - ``POST`` + * - ``POST`` + - ``/api/v2/dags/{dag_id}/favorite`` + - ``DAG`` + - ``GET`` + * - ``POST`` + - ``/api/v2/dags/{dag_id}/unfavorite`` + - ``DAG`` + - ``GET`` + * - ``POST`` + - ``/api/v2/pools`` + - ``Pool`` + - ``POST`` + * - ``POST`` + - ``/api/v2/variables`` + - ``Variable`` + - ``POST`` + * - ``PUT`` + - ``/api/v2/assets/{asset_id}/states/{key:path}`` + - ``Asset`` + - ``PUT`` + * - ``PUT`` + - ``/api/v2/backfills`` + - ``DAG.RUN`` + - ``PUT`` + * - ``PUT`` + - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/states/{key:path}`` + - ``DAG.TASK_INSTANCE`` + - ``PUT`` + * - ``PUT`` + - ``/api/v2/parseDagFile/{file_token}`` + - ``DAG`` + - ``PUT`` diff --git a/scripts/ci/prek/extract_permissions.py b/scripts/ci/prek/extract_permissions.py new file mode 100644 index 0000000000000..e8c44e9b9fdc3 --- /dev/null +++ b/scripts/ci/prek/extract_permissions.py @@ -0,0 +1,489 @@ +#!/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.""" + + http_method: str # GET / POST / PATCH / PUT / DELETE + full_path: str # full route path, e.g. /api/v2/dags/{dag_id} + 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"" + + +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_router_prefix(tree: ast.Module) -> str: + """ + Find some_router = AirflowRouter(prefix="...") at module level. + + Returns the prefix string or "" if not found. + """ + for node in tree.body: + if not (isinstance(node, ast.Assign) 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 + for kw in call.keywords: + if kw.arg == "prefix" and isinstance(kw.value, ast.Constant): + return kw.value.value + return "" + + +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): + 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 + 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" + 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_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) + router_prefix = _extract_router_prefix(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 … + http_verb = decorator.func.attr.upper() if isinstance(decorator.func, ast.Attribute) else None + if http_verb not in {"GET", "POST", "PATCH", "PUT", "DELETE", "HEAD"}: + continue + + # Find dependencies=[...] kwarg + deps_kwarg = next( + (kw for kw in decorator.keywords if kw.arg == "dependencies"), + None, + ) + if deps_kwarg is None or not isinstance(deps_kwarg.value, ast.List): + continue + + # Resolve the route path + route_suffix = "" + if decorator.args: + route_suffix = _resolve_string_node(decorator.args[0], module_consts) + full_path = API_PREFIX + router_prefix + route_suffix + + # Extract tag (for grouping in the RST table) + tag = _extract_tag_from_decorator(decorator) + + # 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 + + results.append( + PermissionEntry( + http_method=http_verb, + full_path=full_path, + tag=tag, + resource=resource, + required_permission=permission, + 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] = [] + for entry in sorted(all_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 0000000000000..3bf57052c4fca --- /dev/null +++ b/scripts/tests/ci/prek/test_extract_permissions.py @@ -0,0 +1,704 @@ +# 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_router_prefix, + _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(" 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_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_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_skipped(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 entries == [] + + 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): + assert all_entries == sorted(all_entries) + + 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 "= 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 "= 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) + + +# =========================================================================== +# 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 " Date: Thu, 28 May 2026 01:03:56 +0530 Subject: [PATCH 2/6] Fix mypy typing issue in permission extractor --- scripts/ci/prek/extract_permissions.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/ci/prek/extract_permissions.py b/scripts/ci/prek/extract_permissions.py index e8c44e9b9fdc3..c2d4078d023a8 100644 --- a/scripts/ci/prek/extract_permissions.py +++ b/scripts/ci/prek/extract_permissions.py @@ -125,7 +125,7 @@ def _extract_router_prefix(tree: ast.Module) -> str: if call_name != "AirflowRouter": continue for kw in call.keywords: - if kw.arg == "prefix" and isinstance(kw.value, ast.Constant): + if kw.arg == "prefix" and isinstance(kw.value, ast.Constant) and isinstance(kw.value.value, str): return kw.value.value return "" @@ -162,7 +162,7 @@ def _extract_method_arg(call_node: ast.Call) -> str: for kw in call_node.keywords: if kw.arg == "method": val = kw.value - if isinstance(val, ast.Constant): + if isinstance(val, ast.Constant) and isinstance(val.value, str): return val.value.upper() return ast.unparse(val).strip("\"'").upper() @@ -312,6 +312,8 @@ def extract_from_file(path: pathlib.Path) -> list[PermissionEntry]: 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( From d3d3c3cfcd943d7718a34f26efea2791089ee2a1 Mon Sep 17 00:00:00 2001 From: Durgaprasad M L Date: Thu, 28 May 2026 09:56:16 +0530 Subject: [PATCH 3/6] chore: rerun flaky CI From 74ad787b06f3773a9f00de0453467c11d4481f33 Mon Sep 17 00:00:00 2001 From: Durgaprasad M L Date: Fri, 29 May 2026 18:18:17 +0530 Subject: [PATCH 4/6] Add prek hook and sort generated API permission docs --- .pre-commit-config.yaml | 10 + .../docs/security/api_permissions_ref.rst | 438 +++++++++--------- scripts/ci/prek/extract_permissions.py | 2 +- 3 files changed, 230 insertions(+), 220 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 26b69f63d814f..697bf7d840bed 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -599,6 +599,16 @@ 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$| + ^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 index e1c3caf0c90e8..d67a68121a35a 100644 --- a/airflow-core/docs/security/api_permissions_ref.rst +++ b/airflow-core/docs/security/api_permissions_ref.rst @@ -46,74 +46,6 @@ source code so it stays up to date as endpoints are added or changed. - Endpoint path - Resource - Required permission - * - ``DELETE`` - - ``/api/v2/assets/{asset_id}/queuedEvents`` - - ``Asset`` - - ``DELETE`` - * - ``DELETE`` - - ``/api/v2/assets/{asset_id}/queuedEvents`` - - ``DAG`` - - ``GET`` - * - ``DELETE`` - - ``/api/v2/assets/{asset_id}/states`` - - ``Asset`` - - ``DELETE`` - * - ``DELETE`` - - ``/api/v2/assets/{asset_id}/states/{key:path}`` - - ``Asset`` - - ``DELETE`` - * - ``DELETE`` - - ``/api/v2/connections/{connection_id}`` - - ``Connection`` - - ``DELETE`` - * - ``DELETE`` - - ``/api/v2/dags/{dag_id}`` - - ``DAG`` - - ``DELETE`` - * - ``DELETE`` - - ``/api/v2/dags/{dag_id}/assets/queuedEvents`` - - ``Asset`` - - ``DELETE`` - * - ``DELETE`` - - ``/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`` - * - ``DELETE`` - - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}`` - - ``DAG.RUN`` - - ``DELETE`` - * - ``DELETE`` - - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}`` - - ``DAG.TASK_INSTANCE`` - - ``DELETE`` - * - ``DELETE`` - - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/states`` - - ``DAG.TASK_INSTANCE`` - - ``DELETE`` - * - ``DELETE`` - - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/states/{key:path}`` - - ``DAG.TASK_INSTANCE`` - - ``DELETE`` - * - ``DELETE`` - - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/xcomEntries/{xcom_key:path}`` - - ``DAG.XCOM`` - - ``DELETE`` - * - ``DELETE`` - - ``/api/v2/pools/{pool_name:path}`` - - ``Pool`` - - ``DELETE`` - * - ``DELETE`` - - ``/api/v2/variables/{variable_key:path}`` - - ``Variable`` - - ``DELETE`` * - ``GET`` - ``/api/v2/assets`` - ``Asset`` @@ -134,6 +66,10 @@ source code so it stays up to date as endpoints are added or changed. - ``/api/v2/assets/events`` - ``Asset`` - ``GET`` + * - ``POST`` + - ``/api/v2/assets/events`` + - ``Asset`` + - ``POST`` * - ``GET`` - ``/api/v2/assets/{asset_id}`` - ``Asset`` @@ -142,22 +78,54 @@ source code so it stays up to date as endpoints are added or changed. - ``/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}/states`` + - ``Asset`` + - ``DELETE`` * - ``GET`` - ``/api/v2/assets/{asset_id}/states`` - ``Asset`` - ``GET`` + * - ``DELETE`` + - ``/api/v2/assets/{asset_id}/states/{key:path}`` + - ``Asset`` + - ``DELETE`` * - ``GET`` - ``/api/v2/assets/{asset_id}/states/{key:path}`` - ``Asset`` - ``GET`` + * - ``PUT`` + - ``/api/v2/assets/{asset_id}/states/{key:path}`` + - ``Asset`` + - ``PUT`` * - ``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`` @@ -170,10 +138,34 @@ source code so it stays up to date as endpoints are added or changed. - ``/api/v2/connections`` - ``Connection`` - ``GET`` + * - ``PATCH`` + - ``/api/v2/connections`` + - ``Connection`` + - ``multi`` + * - ``POST`` + - ``/api/v2/connections`` + - ``Connection`` + - ``POST`` + * - ``POST`` + - ``/api/v2/connections/defaults`` + - ``Connection`` + - ``POST`` + * - ``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`` @@ -194,10 +186,30 @@ source code so it stays up to date as endpoints are added or changed. - ``/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`` @@ -206,6 +218,14 @@ source code so it stays up to date as endpoints are added or changed. - ``/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`` @@ -214,30 +234,86 @@ source code so it stays up to date as endpoints are added or changed. - ``/api/v2/dags/{dag_id}/assets/{asset_id}/queuedEvents`` - ``DAG`` - ``GET`` + * - ``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`` @@ -254,14 +330,26 @@ source code so it stays up to date as endpoints are added or changed. - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/logs/{try_number}`` - ``DAG`` - ``GET`` + * - ``DELETE`` + - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/states`` + - ``DAG.TASK_INSTANCE`` + - ``DELETE`` * - ``GET`` - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/states`` - ``DAG.TASK_INSTANCE`` - ``GET`` + * - ``DELETE`` + - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/states/{key:path}`` + - ``DAG.TASK_INSTANCE`` + - ``DELETE`` * - ``GET`` - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/states/{key:path}`` - ``DAG.TASK_INSTANCE`` - ``GET`` + * - ``PUT`` + - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/states/{key:path}`` + - ``DAG.TASK_INSTANCE`` + - ``PUT`` * - ``GET`` - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/tries`` - ``DAG.TASK_INSTANCE`` @@ -274,22 +362,46 @@ source code so it stays up to date as endpoints are added or changed. - ``/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`` @@ -326,6 +438,10 @@ source code so it stays up to date as endpoints are added or changed. - ``/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`` @@ -334,6 +450,10 @@ source code so it stays up to date as endpoints are added or changed. - ``/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`` @@ -354,6 +474,10 @@ source code so it stays up to date as endpoints are added or changed. - ``/api/v2/jobs`` - ``View.JOBS`` - ``JOBS`` + * - ``PUT`` + - ``/api/v2/parseDagFile/{file_token}`` + - ``DAG`` + - ``PUT`` * - ``GET`` - ``/api/v2/plugins`` - ``View.PLUGINS`` @@ -366,10 +490,26 @@ source code so it stays up to date as endpoints are added or changed. - ``/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`` @@ -378,163 +518,23 @@ source code so it stays up to date as endpoints are added or changed. - ``/api/v2/variables`` - ``Variable`` - ``GET`` - * - ``GET`` - - ``/api/v2/variables/{variable_key:path}`` - - ``Variable`` - - ``GET`` - * - ``PATCH`` - - ``/api/v2/connections`` - - ``Connection`` - - ``multi`` - * - ``PATCH`` - - ``/api/v2/connections/{connection_id}`` - - ``Connection`` - - ``PUT`` - * - ``PATCH`` - - ``/api/v2/dags`` - - ``DAG`` - - ``PUT`` - * - ``PATCH`` - - ``/api/v2/dags/{dag_id}`` - - ``DAG`` - - ``PUT`` - * - ``PATCH`` - - ``/api/v2/dags/{dag_id}/dagRuns`` - - ``DAG.RUN`` - - ``multi`` - * - ``PATCH`` - - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}`` - - ``DAG.RUN`` - - ``PUT`` - * - ``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`` - * - ``PATCH`` - - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances`` - - ``DAG.TASK_INSTANCE`` - - ``PUT`` - * - ``PATCH`` - - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}`` - - ``DAG.TASK_INSTANCE`` - - ``PUT`` - * - ``PATCH`` - - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/dry_run`` - - ``DAG.TASK_INSTANCE`` - - ``PUT`` - * - ``PATCH`` - - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/xcomEntries/{xcom_key:path}`` - - ``DAG.XCOM`` - - ``PUT`` - * - ``PATCH`` - - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/{map_index}`` - - ``DAG.TASK_INSTANCE`` - - ``PUT`` - * - ``PATCH`` - - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/{map_index}/dry_run`` - - ``DAG.TASK_INSTANCE`` - - ``PUT`` - * - ``PATCH`` - - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/{map_index}/hitlDetails`` - - ``DAG.HITL_DETAIL`` - - ``PUT`` - * - ``PATCH`` - - ``/api/v2/pools`` - - ``Pool`` - - ``multi`` - * - ``PATCH`` - - ``/api/v2/pools/{pool_name:path}`` - - ``Pool`` - - ``PUT`` * - ``PATCH`` - ``/api/v2/variables`` - ``Variable`` - ``multi`` - * - ``PATCH`` - - ``/api/v2/variables/{variable_key:path}`` - - ``Variable`` - - ``PUT`` - * - ``POST`` - - ``/api/v2/assets/events`` - - ``Asset`` - - ``POST`` - * - ``POST`` - - ``/api/v2/assets/{asset_id}/materialize`` - - ``Asset`` - - ``POST`` - * - ``POST`` - - ``/api/v2/backfills`` - - ``DAG.RUN`` - - ``POST`` - * - ``POST`` - - ``/api/v2/connections`` - - ``Connection`` - - ``POST`` - * - ``POST`` - - ``/api/v2/connections/defaults`` - - ``Connection`` - - ``POST`` - * - ``POST`` - - ``/api/v2/connections/test`` - - ``Connection`` - - ``POST`` - * - ``POST`` - - ``/api/v2/dags/{dag_id}/clearTaskInstances`` - - ``DAG.TASK_INSTANCE`` - - ``PUT`` - * - ``POST`` - - ``/api/v2/dags/{dag_id}/dagRuns`` - - ``DAG.RUN`` - - ``POST`` - * - ``POST`` - - ``/api/v2/dags/{dag_id}/dagRuns/list`` - - ``DAG.RUN`` - - ``GET`` - * - ``POST`` - - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/clear`` - - ``DAG.RUN`` - - ``PUT`` - * - ``POST`` - - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/list`` - - ``DAG.TASK_INSTANCE`` - - ``GET`` - * - ``POST`` - - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/xcomEntries`` - - ``DAG.XCOM`` - - ``POST`` - * - ``POST`` - - ``/api/v2/dags/{dag_id}/favorite`` - - ``DAG`` - - ``GET`` - * - ``POST`` - - ``/api/v2/dags/{dag_id}/unfavorite`` - - ``DAG`` - - ``GET`` - * - ``POST`` - - ``/api/v2/pools`` - - ``Pool`` - - ``POST`` * - ``POST`` - ``/api/v2/variables`` - ``Variable`` - ``POST`` - * - ``PUT`` - - ``/api/v2/assets/{asset_id}/states/{key:path}`` - - ``Asset`` - - ``PUT`` - * - ``PUT`` - - ``/api/v2/backfills`` - - ``DAG.RUN`` - - ``PUT`` - * - ``PUT`` - - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/states/{key:path}`` - - ``DAG.TASK_INSTANCE`` - - ``PUT`` - * - ``PUT`` - - ``/api/v2/parseDagFile/{file_token}`` - - ``DAG`` + * - ``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`` diff --git a/scripts/ci/prek/extract_permissions.py b/scripts/ci/prek/extract_permissions.py index c2d4078d023a8..5feaa646f9a60 100644 --- a/scripts/ci/prek/extract_permissions.py +++ b/scripts/ci/prek/extract_permissions.py @@ -51,8 +51,8 @@ class PermissionEntry: """One HTTP operation's permission requirement.""" - http_method: str # GET / POST / PATCH / PUT / DELETE 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" From c4adf9b7dbd7f82d57b0aa15fdb212dd0bd20e98 Mon Sep 17 00:00:00 2001 From: Durgaprasad M L Date: Fri, 29 May 2026 21:49:57 +0530 Subject: [PATCH 5/6] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .pre-commit-config.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 697bf7d840bed..558cfc5066c11 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -606,6 +606,7 @@ repos: 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 From 0959f23ed2ce278ceb67c31c6de8c6ecba58e84a Mon Sep 17 00:00:00 2001 From: Durgaprasad M L Date: Fri, 29 May 2026 22:12:43 +0530 Subject: [PATCH 6/6] Handle positional DAG access entity extraction --- .../docs/security/api_permissions_ref.rst | 6 +++--- scripts/ci/prek/extract_permissions.py | 5 +++++ .../tests/ci/prek/test_extract_permissions.py | 21 +++++++++++++++++++ 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/airflow-core/docs/security/api_permissions_ref.rst b/airflow-core/docs/security/api_permissions_ref.rst index d67a68121a35a..4c7b055735bfe 100644 --- a/airflow-core/docs/security/api_permissions_ref.rst +++ b/airflow-core/docs/security/api_permissions_ref.rst @@ -316,11 +316,11 @@ source code so it stays up to date as endpoints are added or changed. - ``PUT`` * - ``GET`` - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/externalLogUrl/{try_number}`` - - ``DAG`` + - ``DAG.TASK_LOGS`` - ``GET`` * - ``GET`` - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/links`` - - ``DAG`` + - ``DAG.TASK_INSTANCE`` - ``GET`` * - ``GET`` - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/listMapped`` @@ -328,7 +328,7 @@ source code so it stays up to date as endpoints are added or changed. - ``GET`` * - ``GET`` - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/logs/{try_number}`` - - ``DAG`` + - ``DAG.TASK_LOGS`` - ``GET`` * - ``DELETE`` - ``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/states`` diff --git a/scripts/ci/prek/extract_permissions.py b/scripts/ci/prek/extract_permissions.py index 5feaa646f9a60..f49dff71d742a 100644 --- a/scripts/ci/prek/extract_permissions.py +++ b/scripts/ci/prek/extract_permissions.py @@ -188,10 +188,15 @@ def _extract_entity_arg(call_node: ast.Call) -> str | None: 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 diff --git a/scripts/tests/ci/prek/test_extract_permissions.py b/scripts/tests/ci/prek/test_extract_permissions.py index 3bf57052c4fca..10bcf28040686 100644 --- a/scripts/tests/ci/prek/test_extract_permissions.py +++ b/scripts/tests/ci/prek/test_extract_permissions.py @@ -329,6 +329,27 @@ def delete_variable(variable_key: str): ... 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(