This is an automated email from the ASF dual-hosted git repository.

vincbeck pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 551870b59dd Update `AwsAuthManager` to support multi-team #65371 
(#65393)
551870b59dd is described below

commit 551870b59dd671d73c3b15cb02aa40be9c55be65
Author: Haseeb Malik <[email protected]>
AuthorDate: Tue Apr 21 10:35:18 2026 -0400

    Update `AwsAuthManager` to support multi-team #65371 (#65393)
---
 .../amazon/aws/auth_manager/avp/entities.py        |   1 +
 .../amazon/aws/auth_manager/avp/facade.py          |  22 ++-
 .../amazon/aws/auth_manager/avp/schema.json        | 198 +++++++++++++++++++--
 .../amazon/aws/auth_manager/aws_auth_manager.py    |  30 ++++
 .../aws/auth_manager/test_aws_auth_manager.py      | 132 +++++++++++++-
 5 files changed, 364 insertions(+), 19 deletions(-)

diff --git 
a/providers/amazon/src/airflow/providers/amazon/aws/auth_manager/avp/entities.py
 
b/providers/amazon/src/airflow/providers/amazon/aws/auth_manager/avp/entities.py
index 87e9ed70bd1..6894fad6255 100644
--- 
a/providers/amazon/src/airflow/providers/amazon/aws/auth_manager/avp/entities.py
+++ 
b/providers/amazon/src/airflow/providers/amazon/aws/auth_manager/avp/entities.py
@@ -42,6 +42,7 @@ class AvpEntities(Enum):
     DAG = "Dag"
     MENU = "Menu"
     POOL = "Pool"
+    TEAM = "Team"
     VARIABLE = "Variable"
     VIEW = "View"
 
diff --git 
a/providers/amazon/src/airflow/providers/amazon/aws/auth_manager/avp/facade.py 
b/providers/amazon/src/airflow/providers/amazon/aws/auth_manager/avp/facade.py
index a6de7be60b7..ac836690ce0 100644
--- 
a/providers/amazon/src/airflow/providers/amazon/aws/auth_manager/avp/facade.py
+++ 
b/providers/amazon/src/airflow/providers/amazon/aws/auth_manager/avp/facade.py
@@ -58,6 +58,7 @@ class IsAuthorizedRequest(TypedDict, total=False):
     entity_type: AvpEntities
     entity_id: str | None
     context: dict | None
+    team_name: str | None
 
 
 class AwsAuthManagerAmazonVerifiedPermissionsFacade(LoggingMixin):
@@ -87,6 +88,7 @@ class 
AwsAuthManagerAmazonVerifiedPermissionsFacade(LoggingMixin):
         user: AwsAuthManagerUser,
         entity_id: str | None = None,
         context: dict | None = None,
+        team_name: str | None = None,
     ) -> bool:
         """
         Make an authorization decision against Amazon Verified Permissions.
@@ -102,7 +104,9 @@ class 
AwsAuthManagerAmazonVerifiedPermissionsFacade(LoggingMixin):
         :param entity_id: the entity ID the user accesses. If not provided, 
all entities of the type will be
             considered.
         :param context: optional additional context to pass to Amazon Verified 
Permissions.
+        :param team_name: optional team name for multi-team authorization.
         """
+        context = self._enrich_context_with_team(context, team_name)
         entity_list = self._get_user_group_entities(user)
 
         self.log.debug(
@@ -259,6 +263,21 @@ class 
AwsAuthManagerAmazonVerifiedPermissionsFacade(LoggingMixin):
         ]
         return [user_entity, *group_entities]
 
+    @staticmethod
+    def _enrich_context_with_team(context: dict | None, team_name: str | None) 
-> dict | None:
+        """
+        Add team_name to context if provided.
+
+        :param context: existing context dict or None
+        :param team_name: team name to add to context
+        """
+        if not team_name:
+            return context
+        team_context = {"team_name": {"string": team_name}}
+        if context is None:
+            return team_context
+        return {**context, **team_context}
+
     @staticmethod
     def _build_context(context: dict | None) -> dict | None:
         if context is None or len(context) == 0:
@@ -275,6 +294,7 @@ class 
AwsAuthManagerAmazonVerifiedPermissionsFacade(LoggingMixin):
         :param request: the request information
         :param user: the user
         """
+        context = self._enrich_context_with_team(request.get("context"), 
request.get("team_name"))
         return prune_dict(
             {
                 "principal": {"entityType": get_entity_type(AvpEntities.USER), 
"entityId": user.get_id()},
@@ -288,6 +308,6 @@ class 
AwsAuthManagerAmazonVerifiedPermissionsFacade(LoggingMixin):
                     "entityType": get_entity_type(request["entity_type"]),
                     "entityId": request.get("entity_id", "*"),
                 },
-                "context": self._build_context(request.get("context")),
+                "context": self._build_context(context),
             }
         )
diff --git 
a/providers/amazon/src/airflow/providers/amazon/aws/auth_manager/avp/schema.json
 
b/providers/amazon/src/airflow/providers/amazon/aws/auth_manager/avp/schema.json
index a90b9197115..cef4912bb4f 100644
--- 
a/providers/amazon/src/airflow/providers/amazon/aws/auth_manager/avp/schema.json
+++ 
b/providers/amazon/src/airflow/providers/amazon/aws/auth_manager/avp/schema.json
@@ -64,31 +64,76 @@
             "Connection.DELETE": {
                 "appliesTo": {
                     "principalTypes": ["User"],
-                    "resourceTypes": ["Connection"]
+                    "resourceTypes": ["Connection"],
+                    "context": {
+                        "attributes": {
+                            "team_name": {
+                                "type": "String",
+                                "required": false
+                            }
+                        },
+                        "type": "Record"
+                    }
                 }
             },
             "Connection.GET": {
                 "appliesTo": {
                     "principalTypes": ["User"],
-                    "resourceTypes": ["Connection"]
+                    "resourceTypes": ["Connection"],
+                    "context": {
+                        "attributes": {
+                            "team_name": {
+                                "type": "String",
+                                "required": false
+                            }
+                        },
+                        "type": "Record"
+                    }
                 }
             },
             "Connection.LIST": {
                 "appliesTo": {
                     "principalTypes": ["User"],
-                    "resourceTypes": ["Connection"]
+                    "resourceTypes": ["Connection"],
+                    "context": {
+                        "attributes": {
+                            "team_name": {
+                                "type": "String",
+                                "required": false
+                            }
+                        },
+                        "type": "Record"
+                    }
                 }
             },
             "Connection.POST": {
                 "appliesTo": {
                     "principalTypes": ["User"],
-                    "resourceTypes": ["Connection"]
+                    "resourceTypes": ["Connection"],
+                    "context": {
+                        "attributes": {
+                            "team_name": {
+                                "type": "String",
+                                "required": false
+                            }
+                        },
+                        "type": "Record"
+                    }
                 }
             },
             "Connection.PUT": {
                 "appliesTo": {
                     "principalTypes": ["User"],
-                    "resourceTypes": ["Connection"]
+                    "resourceTypes": ["Connection"],
+                    "context": {
+                        "attributes": {
+                            "team_name": {
+                                "type": "String",
+                                "required": false
+                            }
+                        },
+                        "type": "Record"
+                    }
                 }
             },
             "Custom.DELETE": {
@@ -142,6 +187,10 @@
                             "dag_entity": {
                                 "type": "String",
                                 "required": false
+                            },
+                            "team_name": {
+                                "type": "String",
+                                "required": false
                             }
                         },
                         "type": "Record"
@@ -157,6 +206,10 @@
                             "dag_entity": {
                                 "required": false,
                                 "type": "String"
+                            },
+                            "team_name": {
+                                "required": false,
+                                "type": "String"
                             }
                         },
                         "type": "Record"
@@ -172,6 +225,10 @@
                             "dag_entity": {
                                 "required": false,
                                 "type": "String"
+                            },
+                            "team_name": {
+                                "required": false,
+                                "type": "String"
                             }
                         },
                         "type": "Record"
@@ -187,6 +244,10 @@
                             "dag_entity": {
                                 "required": false,
                                 "type": "String"
+                            },
+                            "team_name": {
+                                "required": false,
+                                "type": "String"
                             }
                         },
                         "type": "Record"
@@ -202,6 +263,10 @@
                             "dag_entity": {
                                 "required": false,
                                 "type": "String"
+                            },
+                            "team_name": {
+                                "required": false,
+                                "type": "String"
                             }
                         },
                         "type": "Record"
@@ -217,61 +282,163 @@
             "Pool.DELETE": {
                 "appliesTo": {
                     "principalTypes": ["User"],
-                    "resourceTypes": ["Pool"]
+                    "resourceTypes": ["Pool"],
+                    "context": {
+                        "attributes": {
+                            "team_name": {
+                                "type": "String",
+                                "required": false
+                            }
+                        },
+                        "type": "Record"
+                    }
                 }
             },
             "Pool.GET": {
                 "appliesTo": {
                     "principalTypes": ["User"],
-                    "resourceTypes": ["Pool"]
+                    "resourceTypes": ["Pool"],
+                    "context": {
+                        "attributes": {
+                            "team_name": {
+                                "type": "String",
+                                "required": false
+                            }
+                        },
+                        "type": "Record"
+                    }
                 }
             },
             "Pool.LIST": {
                 "appliesTo": {
                     "principalTypes": ["User"],
-                    "resourceTypes": ["Pool"]
+                    "resourceTypes": ["Pool"],
+                    "context": {
+                        "attributes": {
+                            "team_name": {
+                                "type": "String",
+                                "required": false
+                            }
+                        },
+                        "type": "Record"
+                    }
                 }
             },
             "Pool.POST": {
                 "appliesTo": {
                     "principalTypes": ["User"],
-                    "resourceTypes": ["Pool"]
+                    "resourceTypes": ["Pool"],
+                    "context": {
+                        "attributes": {
+                            "team_name": {
+                                "type": "String",
+                                "required": false
+                            }
+                        },
+                        "type": "Record"
+                    }
                 }
             },
             "Pool.PUT": {
                 "appliesTo": {
                     "principalTypes": ["User"],
-                    "resourceTypes": ["Pool"]
+                    "resourceTypes": ["Pool"],
+                    "context": {
+                        "attributes": {
+                            "team_name": {
+                                "type": "String",
+                                "required": false
+                            }
+                        },
+                        "type": "Record"
+                    }
+                }
+            },
+            "Team.GET": {
+                "appliesTo": {
+                    "principalTypes": ["User"],
+                    "resourceTypes": ["Team"]
+                }
+            },
+            "Team.LIST": {
+                "appliesTo": {
+                    "principalTypes": ["User"],
+                    "resourceTypes": ["Team"]
                 }
             },
             "Variable.DELETE": {
                 "appliesTo": {
                     "principalTypes": ["User"],
-                    "resourceTypes": ["Variable"]
+                    "resourceTypes": ["Variable"],
+                    "context": {
+                        "attributes": {
+                            "team_name": {
+                                "type": "String",
+                                "required": false
+                            }
+                        },
+                        "type": "Record"
+                    }
                 }
             },
             "Variable.GET": {
                 "appliesTo": {
                     "principalTypes": ["User"],
-                    "resourceTypes": ["Variable"]
+                    "resourceTypes": ["Variable"],
+                    "context": {
+                        "attributes": {
+                            "team_name": {
+                                "type": "String",
+                                "required": false
+                            }
+                        },
+                        "type": "Record"
+                    }
                 }
             },
             "Variable.LIST": {
                 "appliesTo": {
                     "principalTypes": ["User"],
-                    "resourceTypes": ["Variable"]
+                    "resourceTypes": ["Variable"],
+                    "context": {
+                        "attributes": {
+                            "team_name": {
+                                "type": "String",
+                                "required": false
+                            }
+                        },
+                        "type": "Record"
+                    }
                 }
             },
             "Variable.POST": {
                 "appliesTo": {
                     "principalTypes": ["User"],
-                    "resourceTypes": ["Variable"]
+                    "resourceTypes": ["Variable"],
+                    "context": {
+                        "attributes": {
+                            "team_name": {
+                                "type": "String",
+                                "required": false
+                            }
+                        },
+                        "type": "Record"
+                    }
                 }
             },
             "Variable.PUT": {
                 "appliesTo": {
                     "principalTypes": ["User"],
-                    "resourceTypes": ["Variable"]
+                    "resourceTypes": ["Variable"],
+                    "context": {
+                        "attributes": {
+                            "team_name": {
+                                "type": "String",
+                                "required": false
+                            }
+                        },
+                        "type": "Record"
+                    }
                 }
             },
             "View.GET": {
@@ -291,6 +458,7 @@
             "Dag": {},
             "Menu": {},
             "Pool": {},
+            "Team": {},
             "Group": {},
             "User": {
                 "memberOfTypes": [
diff --git 
a/providers/amazon/src/airflow/providers/amazon/aws/auth_manager/aws_auth_manager.py
 
b/providers/amazon/src/airflow/providers/amazon/aws/auth_manager/aws_auth_manager.py
index f20f4f0a075..57febc258d0 100644
--- 
a/providers/amazon/src/airflow/providers/amazon/aws/auth_manager/aws_auth_manager.py
+++ 
b/providers/amazon/src/airflow/providers/amazon/aws/auth_manager/aws_auth_manager.py
@@ -56,6 +56,7 @@ if TYPE_CHECKING:
         DagAccessEntity,
         DagDetails,
         PoolDetails,
+        TeamDetails,
         VariableDetails,
     )
     from airflow.api_fastapi.common.types import MenuItem
@@ -125,11 +126,13 @@ class AwsAuthManager(BaseAuthManager[AwsAuthManagerUser]):
         details: ConnectionDetails | None = None,
     ) -> bool:
         connection_id = details.conn_id if details else None
+        team_name = self._get_team_name(details)
         return self.avp_facade.is_authorized(
             method=method,
             entity_type=AvpEntities.CONNECTION,
             user=user,
             entity_id=connection_id,
+            team_name=team_name,
         )
 
     def is_authorized_dag(
@@ -141,6 +144,7 @@ class AwsAuthManager(BaseAuthManager[AwsAuthManagerUser]):
         details: DagDetails | None = None,
     ) -> bool:
         dag_id = details.id if details else None
+        team_name = self._get_team_name(details)
         context = (
             None
             if access_entity is None
@@ -156,6 +160,7 @@ class AwsAuthManager(BaseAuthManager[AwsAuthManagerUser]):
             user=user,
             entity_id=dag_id,
             context=context,
+            team_name=team_name,
         )
 
     def is_authorized_backfill(
@@ -193,22 +198,37 @@ class AwsAuthManager(BaseAuthManager[AwsAuthManagerUser]):
         self, *, method: ResourceMethod, user: AwsAuthManagerUser, details: 
PoolDetails | None = None
     ) -> bool:
         pool_name = details.name if details else None
+        team_name = self._get_team_name(details)
         return self.avp_facade.is_authorized(
             method=method,
             entity_type=AvpEntities.POOL,
             user=user,
             entity_id=pool_name,
+            team_name=team_name,
         )
 
     def is_authorized_variable(
         self, *, method: ResourceMethod, user: AwsAuthManagerUser, details: 
VariableDetails | None = None
     ) -> bool:
         variable_key = details.key if details else None
+        team_name = self._get_team_name(details)
         return self.avp_facade.is_authorized(
             method=method,
             entity_type=AvpEntities.VARIABLE,
             user=user,
             entity_id=variable_key,
+            team_name=team_name,
+        )
+
+    def is_authorized_team(
+        self, *, method: ResourceMethod, user: AwsAuthManagerUser, details: 
TeamDetails | None = None
+    ) -> bool:
+        team_name = details.name if details else None
+        return self.avp_facade.is_authorized(
+            method=method,
+            entity_type=AvpEntities.TEAM,
+            user=user,
+            entity_id=team_name,
         )
 
     def is_authorized_view(
@@ -359,6 +379,7 @@ class AwsAuthManager(BaseAuthManager[AwsAuthManagerUser]):
                 "method": method,
                 "entity_type": AvpEntities.CONNECTION,
                 "entity_id": conn_id,
+                "team_name": team_name,
             }
             requests[conn_id][method] = request
             requests_list.append(request)
@@ -390,6 +411,7 @@ class AwsAuthManager(BaseAuthManager[AwsAuthManagerUser]):
                 "method": method,
                 "entity_type": AvpEntities.DAG,
                 "entity_id": dag_id,
+                "team_name": team_name,
             }
             requests[dag_id][method] = request
             requests_list.append(request)
@@ -421,6 +443,7 @@ class AwsAuthManager(BaseAuthManager[AwsAuthManagerUser]):
                 "method": method,
                 "entity_type": AvpEntities.POOL,
                 "entity_id": pool_name,
+                "team_name": team_name,
             }
             requests[pool_name][method] = request
             requests_list.append(request)
@@ -452,6 +475,7 @@ class AwsAuthManager(BaseAuthManager[AwsAuthManagerUser]):
                 "method": method,
                 "entity_type": AvpEntities.VARIABLE,
                 "entity_id": variable_key,
+                "team_name": team_name,
             }
             requests[variable_key][method] = request
             requests_list.append(request)
@@ -493,6 +517,12 @@ class AwsAuthManager(BaseAuthManager[AwsAuthManagerUser]):
 
         return app
 
+    @staticmethod
+    def _get_team_name(
+        details: ConnectionDetails | DagDetails | PoolDetails | 
VariableDetails | None,
+    ) -> str | None:
+        return getattr(details, "team_name", None) if details else None
+
     @staticmethod
     def _get_menu_item_request(menu_item_text: str) -> IsAuthorizedRequest:
         return {
diff --git 
a/providers/amazon/tests/unit/amazon/aws/auth_manager/test_aws_auth_manager.py 
b/providers/amazon/tests/unit/amazon/aws/auth_manager/test_aws_auth_manager.py
index 8e0ff06c185..6d6bb72ac32 100644
--- 
a/providers/amazon/tests/unit/amazon/aws/auth_manager/test_aws_auth_manager.py
+++ 
b/providers/amazon/tests/unit/amazon/aws/auth_manager/test_aws_auth_manager.py
@@ -24,7 +24,7 @@ import pytest
 
 from airflow.exceptions import AirflowProviderDeprecationWarning
 
-from tests_common.test_utils.version_compat import AIRFLOW_V_3_0_PLUS
+from tests_common.test_utils.version_compat import AIRFLOW_V_3_0_PLUS, 
AIRFLOW_V_3_2_PLUS
 
 if not AIRFLOW_V_3_0_PLUS:
     pytest.skip("AWS auth manager is only compatible with Airflow >= 3.0.0", 
allow_module_level=True)
@@ -40,6 +40,11 @@ from 
airflow.api_fastapi.auth.managers.models.resource_details import (
     PoolDetails,
     VariableDetails,
 )
+
+if AIRFLOW_V_3_2_PLUS:
+    from airflow.api_fastapi.auth.managers.models.resource_details import 
TeamDetails
+else:
+    TeamDetails = None  # type: ignore[assignment,misc]
 from airflow.api_fastapi.common.types import MenuItem
 from airflow.providers.amazon.aws.auth_manager.avp.entities import AvpEntities
 from airflow.providers.amazon.aws.auth_manager.aws_auth_manager import 
AwsAuthManager
@@ -158,6 +163,7 @@ class TestAwsAuthManager:
             entity_type=AvpEntities.CONNECTION,
             user=expected_user,
             entity_id=expected_entity_id,
+            team_name=None,
         )
         assert result
 
@@ -206,6 +212,7 @@ class TestAwsAuthManager:
             user=expected_user,
             entity_id=expected_entity_id,
             context=expected_context,
+            team_name=None,
         )
         assert result
 
@@ -328,7 +335,11 @@ class TestAwsAuthManager:
         result = auth_manager.is_authorized_pool(method=method, 
details=details, user=user)
 
         is_authorized.assert_called_once_with(
-            method=method, entity_type=AvpEntities.POOL, user=expected_user, 
entity_id=expected_entity_id
+            method=method,
+            entity_type=AvpEntities.POOL,
+            user=expected_user,
+            entity_id=expected_entity_id,
+            team_name=None,
         )
         assert result
 
@@ -356,7 +367,122 @@ class TestAwsAuthManager:
         result = auth_manager.is_authorized_variable(method=method, 
details=details, user=user)
 
         is_authorized.assert_called_once_with(
-            method=method, entity_type=AvpEntities.VARIABLE, 
user=expected_user, entity_id=expected_entity_id
+            method=method,
+            entity_type=AvpEntities.VARIABLE,
+            user=expected_user,
+            entity_id=expected_entity_id,
+            team_name=None,
+        )
+        assert result
+
+    @pytest.mark.skipif(not AIRFLOW_V_3_2_PLUS, reason="TeamDetails not 
available before Airflow 3.2.0")
+    @pytest.mark.parametrize(
+        (
+            "function",
+            "details_cls",
+            "details_kwargs",
+            "entity_type",
+            "expected_entity_id",
+            "expected_team_name",
+        ),
+        [
+            (
+                "is_authorized_connection",
+                ConnectionDetails,
+                {"conn_id": "conn_id", "team_name": "team_a"},
+                AvpEntities.CONNECTION,
+                "conn_id",
+                "team_a",
+            ),
+            (
+                "is_authorized_pool",
+                PoolDetails,
+                {"name": "pool1", "team_name": "team_a"},
+                AvpEntities.POOL,
+                "pool1",
+                "team_a",
+            ),
+            (
+                "is_authorized_variable",
+                VariableDetails,
+                {"key": "var1", "team_name": "team_a"},
+                AvpEntities.VARIABLE,
+                "var1",
+                "team_a",
+            ),
+        ],
+    )
+    @patch.object(AwsAuthManager, "avp_facade")
+    def test_is_authorized_with_team_name(
+        self,
+        mock_avp_facade,
+        function,
+        details_cls,
+        details_kwargs,
+        entity_type,
+        expected_entity_id,
+        expected_team_name,
+        auth_manager,
+    ):
+        is_authorized = Mock(return_value=True)
+        mock_avp_facade.is_authorized = is_authorized
+        details = details_cls(**details_kwargs)
+
+        method: ResourceMethod = "GET"
+        result = getattr(auth_manager, function)(method=method, 
details=details, user=mock)
+
+        is_authorized.assert_called_once_with(
+            method=method,
+            entity_type=entity_type,
+            user=mock,
+            entity_id=expected_entity_id,
+            team_name=expected_team_name,
+        )
+        assert result
+
+    @pytest.mark.skipif(not AIRFLOW_V_3_2_PLUS, reason="TeamDetails not 
available before Airflow 3.2.0")
+    @patch.object(AwsAuthManager, "avp_facade")
+    def test_is_authorized_dag_with_team_name(self, mock_avp_facade, 
auth_manager):
+        is_authorized = Mock(return_value=True)
+        mock_avp_facade.is_authorized = is_authorized
+
+        result = auth_manager.is_authorized_dag(
+            method="GET", details=DagDetails(id="dag_1", team_name="team_a"), 
user=mock
+        )
+
+        is_authorized.assert_called_once_with(
+            method="GET",
+            entity_type=AvpEntities.DAG,
+            user=mock,
+            entity_id="dag_1",
+            context=None,
+            team_name="team_a",
+        )
+        assert result
+
+    @patch.object(AwsAuthManager, "avp_facade")
+    def test_is_authorized_team(self, mock_avp_facade, auth_manager):
+        is_authorized = Mock(return_value=True)
+        mock_avp_facade.is_authorized = is_authorized
+
+        result = auth_manager.is_authorized_team(method="GET", details=None, 
user=mock)
+
+        is_authorized.assert_called_once_with(
+            method="GET", entity_type=AvpEntities.TEAM, user=mock, 
entity_id=None
+        )
+        assert result
+
+    @pytest.mark.skipif(not AIRFLOW_V_3_2_PLUS, reason="TeamDetails not 
available before Airflow 3.2.0")
+    @patch.object(AwsAuthManager, "avp_facade")
+    def test_is_authorized_team_with_details(self, mock_avp_facade, 
auth_manager):
+        is_authorized = Mock(return_value=True)
+        mock_avp_facade.is_authorized = is_authorized
+        details = TeamDetails(name="team_a")
+
+        result = auth_manager.is_authorized_team(method="GET", 
details=details, user=mock)
+
+        is_authorized.assert_called_once_with(
+            method="GET", entity_type=AvpEntities.TEAM, user=mock, 
entity_id="team_a"
         )
         assert result
 

Reply via email to