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):
"""