blob: 1f0c68d313eb74ab313d333b67c7315670d08b99 [file]
#!/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_dag_from_file_token": ("DAG", None), # reparse authorizes the file_token's Dags
"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())