You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@airflow.apache.org by vi...@apache.org on 2026/04/23 20:00:35 UTC

(airflow) branch main updated: Add multi-team lookup to Azure Key Vault backend (#65692)

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 462b8e15961 Add multi-team lookup to Azure Key Vault backend (#65692)
462b8e15961 is described below

commit 462b8e159614e3eba71febfc2a70fcbf665cbb47
Author: PrithviBadiga <45...@users.noreply.github.com>
AuthorDate: Thu Apr 23 13:00:26 2026 -0700

    Add multi-team lookup to Azure Key Vault backend (#65692)
---
 .../docs/secrets-backends/azure-key-vault.rst      | 20 +++++++++++
 .../providers/microsoft/azure/secrets/key_vault.py | 36 +++++++++++++++++--
 .../unit/microsoft/azure/secrets/test_key_vault.py | 40 ++++++++++++++++++++++
 3 files changed, 93 insertions(+), 3 deletions(-)

diff --git a/providers/microsoft/azure/docs/secrets-backends/azure-key-vault.rst b/providers/microsoft/azure/docs/secrets-backends/azure-key-vault.rst
index 942d6d806f9..1925e2fa7d7 100644
--- a/providers/microsoft/azure/docs/secrets-backends/azure-key-vault.rst
+++ b/providers/microsoft/azure/docs/secrets-backends/azure-key-vault.rst
@@ -67,6 +67,26 @@ Storing and Retrieving Variables
 If you have set ``variables_prefix`` as ``airflow-variables``, then for an Variable key of ``hello``,
 you would want to store your Variable at ``airflow-variables-hello``.
 
+Multi-team lookup
+"""""""""""""""""
+
+In multi-team mode, this backend looks for team-scoped secrets first and falls back to the global
+secret name when a team-scoped secret is not found.
+
+For connections:
+
+* Team-scoped: ``{connections_prefix}-{team_name}--{conn_id}``
+* Global fallback: ``{connections_prefix}-{conn_id}``
+
+For variables:
+
+* Team-scoped: ``{variables_prefix}-{team_name}--{key}``
+* Global fallback: ``{variables_prefix}-{key}``
+
+Underscores are normalized to the configured separator, so with ``connections_prefix="airflow-connections"``,
+``team_name="team_a"``, and ``conn_id="my_db"``, the backend looks up
+``airflow-connections-team-a--my-db`` before falling back to ``airflow-connections-my-db``.
+
 
 Authentication
 """"""""""""""
diff --git a/providers/microsoft/azure/src/airflow/providers/microsoft/azure/secrets/key_vault.py b/providers/microsoft/azure/src/airflow/providers/microsoft/azure/secrets/key_vault.py
index 613dc331015..d47384df130 100644
--- a/providers/microsoft/azure/src/airflow/providers/microsoft/azure/secrets/key_vault.py
+++ b/providers/microsoft/azure/src/airflow/providers/microsoft/azure/secrets/key_vault.py
@@ -26,6 +26,7 @@ from __future__ import annotations
 
 import logging
 import os
+import re
 from functools import cached_property
 
 from azure.core.exceptions import ResourceNotFoundError
@@ -36,6 +37,8 @@ from airflow.providers.microsoft.azure.utils import get_sync_default_azure_crede
 from airflow.secrets import BaseSecretsBackend
 from airflow.utils.log.logging_mixin import LoggingMixin
 
+TEAM_SEP = "--"
+
 
 class AzureKeyVaultBackend(BaseSecretsBackend, LoggingMixin):
     """
@@ -154,7 +157,10 @@ class AzureKeyVaultBackend(BaseSecretsBackend, LoggingMixin):
         if self.connections_prefix is None:
             return None
 
-        return self._get_secret(self.connections_prefix, conn_id)
+        if self._is_team_specific_accessed_as_global(conn_id, team_name):
+            return None
+
+        return self._get_secret(self.connections_prefix, conn_id, team_name=team_name)
 
     def get_variable(self, key: str, team_name: str | None = None) -> str | None:
         """
@@ -167,7 +173,10 @@ class AzureKeyVaultBackend(BaseSecretsBackend, LoggingMixin):
         if self.variables_prefix is None:
             return None
 
-        return self._get_secret(self.variables_prefix, key)
+        if self._is_team_specific_accessed_as_global(key, team_name):
+            return None
+
+        return self._get_secret(self.variables_prefix, key, team_name=team_name)
 
     def get_config(self, key: str) -> str | None:
         """
@@ -200,13 +209,34 @@ class AzureKeyVaultBackend(BaseSecretsBackend, LoggingMixin):
             path = f"{path_prefix}{sep}{secret_id}"
         return path.replace("_", sep)
 
-    def _get_secret(self, path_prefix: str, secret_id: str) -> str | None:
+    def _build_team_secret_name(self, path_prefix: str, team_name: str, secret_id: str) -> str:
+        """Build a team-scoped secret name using a dedicated separator before the secret id."""
+        team_prefix = self.build_path(path_prefix, team_name, self.sep)
+        normalized_secret_id = secret_id.replace("_", self.sep)
+        return f"{team_prefix}{TEAM_SEP}{normalized_secret_id}"
+
+    def _is_team_specific_accessed_as_global(self, secret_id: str, team_name: str | None = None) -> bool:
+        normalized_secret_id = self.build_path("", secret_id, self.sep)
+        return team_name is None and bool(re.fullmatch(rf".+{re.escape(TEAM_SEP)}.+", normalized_secret_id))
+
+    def _get_secret(self, path_prefix: str, secret_id: str, team_name: str | None = None) -> str | None:
         """
         Get an Azure Key Vault secret value.
 
         :param path_prefix: Prefix for the Path to get Secret
         :param secret_id: Secret Key
         """
+        if team_name:
+            team_secret = self._get_secret_value(
+                path_prefix, self._build_team_secret_name("", team_name, secret_id)
+            )
+            if team_secret is not None:
+                return team_secret
+
+        return self._get_secret_value(path_prefix, secret_id)
+
+    def _get_secret_value(self, path_prefix: str, secret_id: str) -> str | None:
+        """Get an Azure Key Vault secret value for the given prefix and key."""
         name = self.build_path(path_prefix, secret_id, self.sep)
         try:
             secret = self.client.get_secret(name=name)
diff --git a/providers/microsoft/azure/tests/unit/microsoft/azure/secrets/test_key_vault.py b/providers/microsoft/azure/tests/unit/microsoft/azure/secrets/test_key_vault.py
index 6ad744f2196..8e0078535dc 100644
--- a/providers/microsoft/azure/tests/unit/microsoft/azure/secrets/test_key_vault.py
+++ b/providers/microsoft/azure/tests/unit/microsoft/azure/secrets/test_key_vault.py
@@ -73,6 +73,46 @@ class TestAzureKeyVaultBackend:
         mock_client.get_secret.assert_called_with(name="af-secrets-test-mysql-password")
         assert secret_val == "super-secret"
 
+    @mock.patch(f"{KEY_VAULT_MODULE}.AzureKeyVaultBackend.client")
+    def test_get_conn_value_uses_team_specific_secret_first(self, mock_client):
+        mock_client.get_secret.return_value = mock.Mock(value="team-secret")
+
+        backend = AzureKeyVaultBackend()
+        secret_val = backend.get_conn_value("my_db", team_name="team_a")
+
+        assert secret_val == "team-secret"
+        mock_client.get_secret.assert_called_once_with(name="airflow-connections-team-a--my-db")
+
+    @mock.patch(f"{KEY_VAULT_MODULE}.AzureKeyVaultBackend.client")
+    def test_get_variable_falls_back_to_global_secret_when_team_secret_is_missing(self, mock_client):
+        mock_client.get_secret.side_effect = [ResourceNotFoundError, mock.Mock(value="global-value")]
+
+        backend = AzureKeyVaultBackend()
+        secret_val = backend.get_variable("hello", team_name="team_a")
+
+        assert secret_val == "global-value"
+        assert mock_client.get_secret.call_args_list == [
+            mock.call(name="airflow-variables-team-a--hello"),
+            mock.call(name="airflow-variables-hello"),
+        ]
+
+    @mock.patch(f"{KEY_VAULT_MODULE}.AzureKeyVaultBackend.client")
+    def test_get_variable_uses_team_secret_with_custom_prefix(self, mock_client):
+        mock_client.get_secret.return_value = mock.Mock(value="team-value")
+
+        backend = AzureKeyVaultBackend(variables_prefix="custom-variables")
+        secret_val = backend.get_variable("hello", team_name="team_a")
+
+        assert secret_val == "team-value"
+        mock_client.get_secret.assert_called_once_with(name="custom-variables-team-a--hello")
+
+    @mock.patch(f"{KEY_VAULT_MODULE}.AzureKeyVaultBackend.client")
+    def test_get_variable_returns_none_for_team_scoped_key_without_team_name(self, mock_client):
+        backend = AzureKeyVaultBackend()
+
+        assert backend.get_variable("teama--hello") is None
+        mock_client.get_secret.assert_not_called()
+
     @mock.patch(f"{KEY_VAULT_MODULE}.AzureKeyVaultBackend._get_secret")
     def test_variable_prefix_none_value(self, mock_get_secret):
         """