Skip to content

Commit 6cc29af

Browse files
amoghrajeshambika-garg
authored andcommitted
AIP-72: Improving Operator Links Interface to Prevent User Code Execution in Webserver (apache#46613)
Operator Links interface changed to not run user code in Airflow Webserver The Operator Extra links, which can be defined either via plugins or custom operators now do not execute any user code in the Airflow Webserver, but instead push the "full" links to XCom backend and the value is again fetched from the XCom backend when viewing task details in grid view. Example: ``` @attr.s(auto_attribs=True) class CustomBaseIndexOpLink(BaseOperatorLink): """Custom Operator Link for Google BigQuery Console.""" index: int = attr.ib() @Property def name(self) -> str: return f"BigQuery Console #{self.index + 1}" @Property def xcom_key(self) -> str: return f"bigquery_{self.index + 1}" def get_link(self, operator, *, ti_key): search_queries = XCom.get_one( task_id=ti_key.task_id, dag_id=ti_key.dag_id, run_id=ti_key.run_id, key="search_query" ) if not search_queries: return None if len(search_queries) < self.index: return None search_query = search_queries[self.index] return f"https://console.cloud.google.com/bigquery?j={search_query}" ```
1 parent 9b26e4f commit 6cc29af

20 files changed

Lines changed: 465 additions & 420 deletions

File tree

airflow/models/abstractoperator.py

Lines changed: 0 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,12 @@
1818
from __future__ import annotations
1919

2020
import datetime
21-
import inspect
2221
from collections.abc import Iterable, Sequence
23-
from functools import cached_property
2422
from typing import TYPE_CHECKING, Any, Callable
2523

2624
from sqlalchemy import select
2725

2826
from airflow.configuration import conf
29-
from airflow.exceptions import AirflowException
3027
from airflow.sdk.definitions._internal.abstractoperator import (
3128
AbstractOperator as TaskSDKAbstractOperator,
3229
NotMapped as NotMapped, # Re-export this for compat
@@ -42,7 +39,6 @@
4239
if TYPE_CHECKING:
4340
from sqlalchemy.orm import Session
4441

45-
from airflow.models.baseoperatorlink import BaseOperatorLink
4642
from airflow.models.dag import DAG as SchedulerDAG
4743
from airflow.models.taskinstance import TaskInstance
4844
from airflow.sdk.definitions.baseoperator import BaseOperator
@@ -157,64 +153,6 @@ def priority_weight_total(self) -> int:
157153
)
158154
)
159155

160-
@cached_property
161-
def operator_extra_link_dict(self) -> dict[str, Any]:
162-
"""Returns dictionary of all extra links for the operator."""
163-
op_extra_links_from_plugin: dict[str, Any] = {}
164-
from airflow import plugins_manager
165-
166-
plugins_manager.initialize_extra_operators_links_plugins()
167-
if plugins_manager.operator_extra_links is None:
168-
raise AirflowException("Can't load operators")
169-
for ope in plugins_manager.operator_extra_links:
170-
if ope.operators and self.operator_class in ope.operators:
171-
op_extra_links_from_plugin.update({ope.name: ope})
172-
173-
operator_extra_links_all = {link.name: link for link in self.operator_extra_links}
174-
# Extra links defined in Plugins overrides operator links defined in operator
175-
operator_extra_links_all.update(op_extra_links_from_plugin)
176-
177-
return operator_extra_links_all
178-
179-
@cached_property
180-
def global_operator_extra_link_dict(self) -> dict[str, Any]:
181-
"""Returns dictionary of all global extra links."""
182-
from airflow import plugins_manager
183-
184-
plugins_manager.initialize_extra_operators_links_plugins()
185-
if plugins_manager.global_operator_extra_links is None:
186-
raise AirflowException("Can't load operators")
187-
return {link.name: link for link in plugins_manager.global_operator_extra_links}
188-
189-
@cached_property
190-
def extra_links(self) -> list[str]:
191-
return sorted(set(self.operator_extra_link_dict).union(self.global_operator_extra_link_dict))
192-
193-
def get_extra_links(self, ti: TaskInstance, link_name: str) -> str | None:
194-
"""
195-
For an operator, gets the URLs that the ``extra_links`` entry points to.
196-
197-
:meta private:
198-
199-
:raise ValueError: The error message of a ValueError will be passed on through to
200-
the fronted to show up as a tooltip on the disabled link.
201-
:param ti: The TaskInstance for the URL being searched for.
202-
:param link_name: The name of the link we're looking for the URL for. Should be
203-
one of the options specified in ``extra_links``.
204-
"""
205-
link: BaseOperatorLink | None = self.operator_extra_link_dict.get(link_name)
206-
if not link:
207-
link = self.global_operator_extra_link_dict.get(link_name)
208-
if not link:
209-
return None
210-
211-
parameters = inspect.signature(link.get_link).parameters
212-
old_signature = all(name != "ti_key" for name, p in parameters.items() if p.kind != p.VAR_KEYWORD)
213-
214-
if old_signature:
215-
return link.get_link(self.unmap(None), ti.dag_run.logical_date) # type: ignore[misc]
216-
return link.get_link(self.unmap(None), ti_key=ti.key)
217-
218156
def expand_mapped_task(self, run_id: str, *, session: Session) -> tuple[Sequence[TaskInstance], int]:
219157
"""
220158
Create the mapped task instances for mapped task.

airflow/models/baseoperatorlink.py

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,53 @@
2020
from abc import ABCMeta, abstractmethod
2121
from typing import TYPE_CHECKING, ClassVar
2222

23-
import attr
23+
import attrs
24+
25+
from airflow.models.xcom import BaseXCom
26+
from airflow.utils.log.logging_mixin import LoggingMixin
2427

2528
if TYPE_CHECKING:
2629
from airflow.models.baseoperator import BaseOperator
2730
from airflow.models.taskinstancekey import TaskInstanceKey
2831

2932

30-
@attr.s(auto_attribs=True)
33+
@attrs.define()
34+
class XComOperatorLink(LoggingMixin):
35+
"""A generic operator link class that can retrieve link only using XCOMs. Used while deserializing operators."""
36+
37+
name: str
38+
xcom_key: str
39+
40+
def get_link(self, operator: BaseOperator, *, ti_key: TaskInstanceKey) -> str:
41+
"""
42+
Retrieve the link from the XComs.
43+
44+
:param operator: The Airflow operator object this link is associated to.
45+
:param ti_key: TaskInstance ID to return link for.
46+
:return: link to external system, but by pulling it from XComs
47+
"""
48+
self.log.info(
49+
"Attempting to retrieve link from XComs with key: %s for task id: %s", self.xcom_key, ti_key
50+
)
51+
value = BaseXCom.get_one(
52+
key=self.xcom_key,
53+
run_id=ti_key.run_id,
54+
dag_id=ti_key.dag_id,
55+
task_id=ti_key.task_id,
56+
map_index=ti_key.map_index,
57+
)
58+
if not value:
59+
self.log.debug(
60+
"No link with name: %s present in XCom as key: %s, returning empty link",
61+
self.name,
62+
self.xcom_key,
63+
)
64+
return ""
65+
# Stripping is a temporary workaround till https://github.com/apache/airflow/issues/46513 is handled.
66+
return value.strip('"')
67+
68+
69+
@attrs.define()
3170
class BaseOperatorLink(metaclass=ABCMeta):
3271
"""Abstract base class that defines how we get an operator link."""
3372

@@ -44,6 +83,17 @@ class BaseOperatorLink(metaclass=ABCMeta):
4483
def name(self) -> str:
4584
"""Name of the link. This will be the button name on the task UI."""
4685

86+
@property
87+
def xcom_key(self) -> str:
88+
"""
89+
XCom key with while the whole "link" for this operator link is stored.
90+
91+
On retrieving with this key, the entire link is returned.
92+
93+
Defaults to `_link_<class name>` if not provided.
94+
"""
95+
return f"_link_{self.__class__.__name__}"
96+
4797
@abstractmethod
4898
def get_link(self, operator: BaseOperator, *, ti_key: TaskInstanceKey) -> str:
4999
"""

airflow/serialization/serialized_objects.py

Lines changed: 77 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,14 @@
4040
from airflow.callbacks.callback_requests import DagCallbackRequest, TaskCallbackRequest
4141
from airflow.exceptions import AirflowException, SerializationError, TaskDeferred
4242
from airflow.models.baseoperator import BaseOperator
43+
from airflow.models.baseoperatorlink import BaseOperatorLink, XComOperatorLink
4344
from airflow.models.connection import Connection
4445
from airflow.models.dag import DAG, _get_model_data_interval
4546
from airflow.models.expandinput import (
4647
EXPAND_INPUT_EMPTY,
4748
create_expand_input,
4849
)
49-
from airflow.models.taskinstance import SimpleTaskInstance
50+
from airflow.models.taskinstance import SimpleTaskInstance, TaskInstance
5051
from airflow.models.taskinstancekey import TaskInstanceKey
5152
from airflow.models.xcom_arg import SchedulerXComArg, deserialize_xcom_arg
5253
from airflow.providers_manager import ProvidersManager
@@ -96,7 +97,6 @@
9697
from inspect import Parameter
9798

9899
from airflow.models import DagRun
99-
from airflow.models.baseoperatorlink import BaseOperatorLink
100100
from airflow.models.expandinput import ExpandInput
101101
from airflow.sdk.definitions._internal.node import DAGNode
102102
from airflow.sdk.types import Operator
@@ -1167,6 +1167,58 @@ def __init__(self, *args, **kwargs):
11671167
self.template_fields = BaseOperator.template_fields
11681168
self.operator_extra_links = BaseOperator.operator_extra_links
11691169

1170+
@cached_property
1171+
def operator_extra_link_dict(self) -> dict[str, BaseOperatorLink]:
1172+
"""Returns dictionary of all extra links for the operator."""
1173+
op_extra_links_from_plugin: dict[str, Any] = {}
1174+
from airflow import plugins_manager
1175+
1176+
plugins_manager.initialize_extra_operators_links_plugins()
1177+
if plugins_manager.operator_extra_links is None:
1178+
raise AirflowException("Can't load operators")
1179+
for ope in plugins_manager.operator_extra_links:
1180+
if ope.operators and self.operator_class in ope.operators:
1181+
op_extra_links_from_plugin.update({ope.name: ope})
1182+
1183+
operator_extra_links_all = {link.name: link for link in self.operator_extra_links}
1184+
# Extra links defined in Plugins overrides operator links defined in operator
1185+
operator_extra_links_all.update(op_extra_links_from_plugin)
1186+
1187+
return operator_extra_links_all
1188+
1189+
@cached_property
1190+
def global_operator_extra_link_dict(self) -> dict[str, Any]:
1191+
"""Returns dictionary of all global extra links."""
1192+
from airflow import plugins_manager
1193+
1194+
plugins_manager.initialize_extra_operators_links_plugins()
1195+
if plugins_manager.global_operator_extra_links is None:
1196+
raise AirflowException("Can't load operators")
1197+
return {link.name: link for link in plugins_manager.global_operator_extra_links}
1198+
1199+
@cached_property
1200+
def extra_links(self) -> list[str]:
1201+
return sorted(set(self.operator_extra_link_dict).union(self.global_operator_extra_link_dict))
1202+
1203+
def get_extra_links(self, ti: TaskInstance, link_name: str) -> str | None:
1204+
"""
1205+
For an operator, gets the URLs that the ``extra_links`` entry points to.
1206+
1207+
:meta private:
1208+
1209+
:raise ValueError: The error message of a ValueError will be passed on through to
1210+
the fronted to show up as a tooltip on the disabled link.
1211+
:param ti: The TaskInstance for the URL being searched for.
1212+
:param link_name: The name of the link we're looking for the URL for. Should be
1213+
one of the options specified in ``extra_links``.
1214+
"""
1215+
link = self.operator_extra_link_dict.get(link_name)
1216+
if not link:
1217+
link = self.global_operator_extra_link_dict.get(link_name)
1218+
if not link:
1219+
return None
1220+
return link.get_link(self.unmap(None), ti_key=ti.key)
1221+
11701222
@property
11711223
def task_type(self) -> str:
11721224
# Overwrites task_type of BaseOperator to use _task_type instead of
@@ -1504,7 +1556,9 @@ def _is_excluded(cls, var: Any, attrname: str, op: DAGNode):
15041556
return super()._is_excluded(var, attrname, op)
15051557

15061558
@classmethod
1507-
def _deserialize_operator_extra_links(cls, encoded_op_links: list) -> dict[str, BaseOperatorLink]:
1559+
def _deserialize_operator_extra_links(
1560+
cls, encoded_op_links: dict[str, str]
1561+
) -> dict[str, XComOperatorLink]:
15081562
"""
15091563
Deserialize Operator Links if the Classes are registered in Airflow Plugins.
15101564
@@ -1521,77 +1575,40 @@ def _deserialize_operator_extra_links(cls, encoded_op_links: list) -> dict[str,
15211575
raise AirflowException("Can't load plugins")
15221576
op_predefined_extra_links = {}
15231577

1524-
for _operator_links_source in encoded_op_links:
1525-
# Get the key, value pair as Tuple where key is OperatorLink ClassName
1526-
# and value is the dictionary containing the arguments passed to the OperatorLink
1527-
#
1528-
# Example of a single iteration:
1529-
#
1530-
# _operator_links_source =
1531-
# {
1532-
# 'airflow.providers.google.cloud.operators.bigquery.BigQueryConsoleIndexableLink': {
1533-
# 'index': 0
1534-
# }
1535-
# },
1536-
#
1537-
# list(_operator_links_source.items()) =
1538-
# [
1539-
# (
1540-
# 'airflow.providers.google.cloud.operators.bigquery.BigQueryConsoleIndexableLink',
1541-
# {'index': 0}
1542-
# )
1543-
# ]
1578+
for name, xcom_key in encoded_op_links.items():
1579+
# Get the name and xcom_key of the encoded operator and use it to create a XComOperatorLink object
1580+
# during deserialization.
15441581
#
1545-
# list(_operator_links_source.items())[0] =
1546-
# (
1547-
# 'airflow.providers.google.cloud.operators.bigquery.BigQueryConsoleIndexableLink',
1548-
# {
1549-
# 'index': 0
1550-
# }
1551-
# )
1552-
1553-
_operator_link_class_path, data = next(iter(_operator_links_source.items()))
1554-
if _operator_link_class_path in get_operator_extra_links():
1555-
single_op_link_class = import_string(_operator_link_class_path)
1556-
elif _operator_link_class_path in plugins_manager.registered_operator_link_classes:
1557-
single_op_link_class = plugins_manager.registered_operator_link_classes[
1558-
_operator_link_class_path
1559-
]
1560-
else:
1561-
log.error("Operator Link class %r not registered", _operator_link_class_path)
1562-
return {}
1563-
1564-
op_link_parameters = {param: cls.deserialize(value) for param, value in data.items()}
1565-
op_predefined_extra_link: BaseOperatorLink = single_op_link_class(**op_link_parameters)
1566-
1582+
# Example:
1583+
# enc_operator['_operator_extra_links'] =
1584+
# {
1585+
# 'airflow': 'airflow_link_key',
1586+
# 'foo-bar': 'link-key',
1587+
# 'no_response': 'key',
1588+
# 'raise_error': 'key'
1589+
# }
1590+
1591+
op_predefined_extra_link = XComOperatorLink(name=name, xcom_key=xcom_key)
15671592
op_predefined_extra_links.update({op_predefined_extra_link.name: op_predefined_extra_link})
15681593

15691594
return op_predefined_extra_links
15701595

15711596
@classmethod
1572-
def _serialize_operator_extra_links(cls, operator_extra_links: Iterable[BaseOperatorLink]):
1597+
def _serialize_operator_extra_links(
1598+
cls, operator_extra_links: Iterable[BaseOperatorLink]
1599+
) -> dict[str, str]:
15731600
"""
15741601
Serialize Operator Links.
15751602
1576-
Store the import path of the OperatorLink and the arguments passed to it.
1603+
Store the "name" of the link mapped with the xcom_key which can be later used to retrieve this
1604+
operator extra link from XComs.
15771605
For example:
1578-
``[{'airflow.providers.google.cloud.links.bigquery.BigQueryDatasetLink': {}}]``
1606+
``{'link-name-1': 'xcom-key-1'}``
15791607
15801608
:param operator_extra_links: Operator Link
15811609
:return: Serialized Operator Link
15821610
"""
1583-
serialize_operator_extra_links = []
1584-
for operator_extra_link in operator_extra_links:
1585-
op_link_arguments = {
1586-
param: cls.serialize(value) for param, value in attrs.asdict(operator_extra_link).items()
1587-
}
1588-
1589-
module_path = (
1590-
f"{operator_extra_link.__class__.__module__}.{operator_extra_link.__class__.__name__}"
1591-
)
1592-
serialize_operator_extra_links.append({module_path: op_link_arguments})
1593-
1594-
return serialize_operator_extra_links
1611+
return {link.name: link.xcom_key for link in operator_extra_links}
15951612

15961613
@classmethod
15971614
def serialize(cls, var: Any, *, strict: bool = False) -> Any:

newsfragments/46613.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Operator Links interface changed to not run user code in Airflow Webserver The Operator Extra links, which can be defined either via plugins or custom operators now do not execute any user code in the Airflow Webserver, but instead push the "full" links to XCom backend and the value is again fetched from the XCom backend when viewing task details in grid view.

providers/amazon/tests/provider_tests/amazon/aws/links/test_base_aws.py

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -194,14 +194,7 @@ def assert_extra_link_url(
194194
)
195195

196196
error_msg = f"{self.full_qualname!r} should be preserved after execution"
197-
assert ti.task.get_extra_links(ti, self.link_class.name) == expected_url, error_msg
198-
199-
serialized_dag = self.dag_maker.get_serialized_data()
200-
deserialized_dag = SerializedDAG.from_dict(serialized_dag)
201-
deserialized_task = deserialized_dag.task_dict[self.task_id]
202-
203-
error_msg = f"{self.full_qualname!r} should be preserved in deserialized tasks after execution"
204-
assert deserialized_task.get_extra_links(ti, self.link_class.name) == expected_url, error_msg
197+
assert task.operator_extra_links[0].get_link(operator=task, ti_key=ti.key) == expected_url, error_msg
205198

206199
def test_link_serialize(self):
207200
"""Test: Operator links should exist for serialized DAG."""
@@ -223,7 +216,7 @@ def test_empty_xcom(self):
223216
deserialized_task = deserialized_dag.task_dict[self.task_id]
224217

225218
assert (
226-
ti.task.get_extra_links(ti, self.link_class.name) == ""
219+
ti.task.operator_extra_links[0].get_link(operator=ti.task, ti_key=ti.key) == ""
227220
), "Operator link should only be added if job id is available in XCom"
228221

229222
assert (

providers/dbt/cloud/tests/provider_tests/dbt/cloud/operators/test_dbt.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -658,7 +658,7 @@ def test_run_job_operator_link(self, conn_id, account_id, create_task_instance_o
658658

659659
ti.xcom_push(key="job_run_url", value=_run_response["data"]["href"])
660660

661-
url = ti.task.get_extra_links(ti, "Monitor Job Run")
661+
url = ti.task.operator_extra_links[0].get_link(operator=ti.task, ti_key=ti.key)
662662

663663
assert url == (
664664
EXPECTED_JOB_RUN_OP_EXTRA_LINK.format(

0 commit comments

Comments
 (0)