Skip to content

Commit 462b8e1

Browse files
Add multi-team lookup to Azure Key Vault backend (#65692)
1 parent 8680686 commit 462b8e1

3 files changed

Lines changed: 93 additions & 3 deletions

File tree

providers/microsoft/azure/docs/secrets-backends/azure-key-vault.rst

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,26 @@ Storing and Retrieving Variables
6767
If you have set ``variables_prefix`` as ``airflow-variables``, then for an Variable key of ``hello``,
6868
you would want to store your Variable at ``airflow-variables-hello``.
6969

70+
Multi-team lookup
71+
"""""""""""""""""
72+
73+
In multi-team mode, this backend looks for team-scoped secrets first and falls back to the global
74+
secret name when a team-scoped secret is not found.
75+
76+
For connections:
77+
78+
* Team-scoped: ``{connections_prefix}-{team_name}--{conn_id}``
79+
* Global fallback: ``{connections_prefix}-{conn_id}``
80+
81+
For variables:
82+
83+
* Team-scoped: ``{variables_prefix}-{team_name}--{key}``
84+
* Global fallback: ``{variables_prefix}-{key}``
85+
86+
Underscores are normalized to the configured separator, so with ``connections_prefix="airflow-connections"``,
87+
``team_name="team_a"``, and ``conn_id="my_db"``, the backend looks up
88+
``airflow-connections-team-a--my-db`` before falling back to ``airflow-connections-my-db``.
89+
7090

7191
Authentication
7292
""""""""""""""

providers/microsoft/azure/src/airflow/providers/microsoft/azure/secrets/key_vault.py

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626

2727
import logging
2828
import os
29+
import re
2930
from functools import cached_property
3031

3132
from azure.core.exceptions import ResourceNotFoundError
@@ -36,6 +37,8 @@
3637
from airflow.secrets import BaseSecretsBackend
3738
from airflow.utils.log.logging_mixin import LoggingMixin
3839

40+
TEAM_SEP = "--"
41+
3942

4043
class AzureKeyVaultBackend(BaseSecretsBackend, LoggingMixin):
4144
"""
@@ -154,7 +157,10 @@ def get_conn_value(self, conn_id: str, team_name: str | None = None) -> str | No
154157
if self.connections_prefix is None:
155158
return None
156159

157-
return self._get_secret(self.connections_prefix, conn_id)
160+
if self._is_team_specific_accessed_as_global(conn_id, team_name):
161+
return None
162+
163+
return self._get_secret(self.connections_prefix, conn_id, team_name=team_name)
158164

159165
def get_variable(self, key: str, team_name: str | None = None) -> str | None:
160166
"""
@@ -167,7 +173,10 @@ def get_variable(self, key: str, team_name: str | None = None) -> str | None:
167173
if self.variables_prefix is None:
168174
return None
169175

170-
return self._get_secret(self.variables_prefix, key)
176+
if self._is_team_specific_accessed_as_global(key, team_name):
177+
return None
178+
179+
return self._get_secret(self.variables_prefix, key, team_name=team_name)
171180

172181
def get_config(self, key: str) -> str | None:
173182
"""
@@ -200,13 +209,34 @@ def build_path(path_prefix: str, secret_id: str, sep: str = "-") -> str:
200209
path = f"{path_prefix}{sep}{secret_id}"
201210
return path.replace("_", sep)
202211

203-
def _get_secret(self, path_prefix: str, secret_id: str) -> str | None:
212+
def _build_team_secret_name(self, path_prefix: str, team_name: str, secret_id: str) -> str:
213+
"""Build a team-scoped secret name using a dedicated separator before the secret id."""
214+
team_prefix = self.build_path(path_prefix, team_name, self.sep)
215+
normalized_secret_id = secret_id.replace("_", self.sep)
216+
return f"{team_prefix}{TEAM_SEP}{normalized_secret_id}"
217+
218+
def _is_team_specific_accessed_as_global(self, secret_id: str, team_name: str | None = None) -> bool:
219+
normalized_secret_id = self.build_path("", secret_id, self.sep)
220+
return team_name is None and bool(re.fullmatch(rf".+{re.escape(TEAM_SEP)}.+", normalized_secret_id))
221+
222+
def _get_secret(self, path_prefix: str, secret_id: str, team_name: str | None = None) -> str | None:
204223
"""
205224
Get an Azure Key Vault secret value.
206225
207226
:param path_prefix: Prefix for the Path to get Secret
208227
:param secret_id: Secret Key
209228
"""
229+
if team_name:
230+
team_secret = self._get_secret_value(
231+
path_prefix, self._build_team_secret_name("", team_name, secret_id)
232+
)
233+
if team_secret is not None:
234+
return team_secret
235+
236+
return self._get_secret_value(path_prefix, secret_id)
237+
238+
def _get_secret_value(self, path_prefix: str, secret_id: str) -> str | None:
239+
"""Get an Azure Key Vault secret value for the given prefix and key."""
210240
name = self.build_path(path_prefix, secret_id, self.sep)
211241
try:
212242
secret = self.client.get_secret(name=name)

providers/microsoft/azure/tests/unit/microsoft/azure/secrets/test_key_vault.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,46 @@ def test_get_secret_value(self, mock_client):
7373
mock_client.get_secret.assert_called_with(name="af-secrets-test-mysql-password")
7474
assert secret_val == "super-secret"
7575

76+
@mock.patch(f"{KEY_VAULT_MODULE}.AzureKeyVaultBackend.client")
77+
def test_get_conn_value_uses_team_specific_secret_first(self, mock_client):
78+
mock_client.get_secret.return_value = mock.Mock(value="team-secret")
79+
80+
backend = AzureKeyVaultBackend()
81+
secret_val = backend.get_conn_value("my_db", team_name="team_a")
82+
83+
assert secret_val == "team-secret"
84+
mock_client.get_secret.assert_called_once_with(name="airflow-connections-team-a--my-db")
85+
86+
@mock.patch(f"{KEY_VAULT_MODULE}.AzureKeyVaultBackend.client")
87+
def test_get_variable_falls_back_to_global_secret_when_team_secret_is_missing(self, mock_client):
88+
mock_client.get_secret.side_effect = [ResourceNotFoundError, mock.Mock(value="global-value")]
89+
90+
backend = AzureKeyVaultBackend()
91+
secret_val = backend.get_variable("hello", team_name="team_a")
92+
93+
assert secret_val == "global-value"
94+
assert mock_client.get_secret.call_args_list == [
95+
mock.call(name="airflow-variables-team-a--hello"),
96+
mock.call(name="airflow-variables-hello"),
97+
]
98+
99+
@mock.patch(f"{KEY_VAULT_MODULE}.AzureKeyVaultBackend.client")
100+
def test_get_variable_uses_team_secret_with_custom_prefix(self, mock_client):
101+
mock_client.get_secret.return_value = mock.Mock(value="team-value")
102+
103+
backend = AzureKeyVaultBackend(variables_prefix="custom-variables")
104+
secret_val = backend.get_variable("hello", team_name="team_a")
105+
106+
assert secret_val == "team-value"
107+
mock_client.get_secret.assert_called_once_with(name="custom-variables-team-a--hello")
108+
109+
@mock.patch(f"{KEY_VAULT_MODULE}.AzureKeyVaultBackend.client")
110+
def test_get_variable_returns_none_for_team_scoped_key_without_team_name(self, mock_client):
111+
backend = AzureKeyVaultBackend()
112+
113+
assert backend.get_variable("teama--hello") is None
114+
mock_client.get_secret.assert_not_called()
115+
76116
@mock.patch(f"{KEY_VAULT_MODULE}.AzureKeyVaultBackend._get_secret")
77117
def test_variable_prefix_none_value(self, mock_get_secret):
78118
"""

0 commit comments

Comments
 (0)