Skip to content

Commit 8680686

Browse files
Add multi-team lookup to Lockbox backend (#65695)
1 parent 6dcfb1a commit 8680686

3 files changed

Lines changed: 134 additions & 8 deletions

File tree

providers/yandex/docs/secrets-backends/yandex-cloud-lockbox-secret-backend.rst

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,48 @@ To check the variable is correctly read from the Lockbox Secret Backend, you can
251251
$ airflow variables get my_variable
252252
some_secret_data
253253
254+
Multi-team lookup
255+
-----------------
256+
257+
In multi-team mode, this backend looks for team-scoped secret names first and falls back to the
258+
global secret name when a team-scoped secret is not found.
259+
260+
To use this mode, keep the backend configured as usual and create team-scoped secrets by inserting
261+
the team name between the configured prefix and the secret identifier, separated from the secret
262+
identifier by a doubled separator.
263+
264+
For example, with this configuration:
265+
266+
.. code-block:: ini
267+
268+
[secrets]
269+
backend = airflow.providers.yandex.secrets.lockbox.LockboxSecretBackend
270+
backend_kwargs = {"connections_prefix": "airflow/connections", "variables_prefix": "airflow/variables"}
271+
272+
the backend expects team-scoped secrets to be stored under paths such as
273+
``airflow/connections/<team_name>//<conn_id>`` or ``airflow/variables/<team_name>//<key>``.
274+
275+
For connections:
276+
277+
* Team-scoped: ``{connections_prefix}/{team_name}//{conn_id}``
278+
* Global fallback: ``{connections_prefix}/{conn_id}``
279+
280+
For variables:
281+
282+
* Team-scoped: ``{variables_prefix}/{team_name}//{key}``
283+
* Global fallback: ``{variables_prefix}/{key}``
284+
285+
For example, with ``connections_prefix="airflow/connections"``, ``team_name="team_a"``, and
286+
``conn_id="my_db"``, the backend looks up ``airflow/connections/team_a//my_db`` before falling back
287+
to ``airflow/connections/my_db``.
288+
289+
This means you can provide both:
290+
291+
* a team-specific secret such as ``airflow/connections/team_a//my_db``
292+
* an optional global default secret such as ``airflow/connections/my_db``
293+
294+
If no ``team_name`` is provided, only the global secret path is used.
295+
254296
Storing and retrieving configs
255297
------------------------------
256298

providers/yandex/src/airflow/providers/yandex/secrets/lockbox.py

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
from __future__ import annotations
2020

21+
import re
2122
from functools import cached_property
2223
from typing import Any
2324

@@ -37,6 +38,8 @@
3738
from airflow.secrets import BaseSecretsBackend
3839
from airflow.utils.log.logging_mixin import LoggingMixin
3940

41+
TEAM_SEP_MULTIPLIER = 2
42+
4043

4144
class LockboxSecretBackend(BaseSecretsBackend, LoggingMixin):
4245
"""
@@ -159,7 +162,10 @@ def get_conn_value(self, conn_id: str, team_name: str | None = None) -> str | No
159162
if conn_id == self.yc_connection_id:
160163
return None
161164

162-
return self._get_secret_value(self.connections_prefix, conn_id)
165+
if self._is_team_specific_accessed_as_global(conn_id, team_name):
166+
return None
167+
168+
return self._get_secret_value(self.connections_prefix, conn_id, team_name=team_name)
163169

164170
def get_variable(self, key: str, team_name: str | None = None) -> str | None:
165171
"""
@@ -172,7 +178,10 @@ def get_variable(self, key: str, team_name: str | None = None) -> str | None:
172178
if self.variables_prefix is None:
173179
return None
174180

175-
return self._get_secret_value(self.variables_prefix, key)
181+
if self._is_team_specific_accessed_as_global(key, team_name):
182+
return None
183+
184+
return self._get_secret_value(self.variables_prefix, key, team_name=team_name)
176185

177186
def get_config(self, key: str) -> str | None:
178187
"""
@@ -243,12 +252,23 @@ def _build_secret_name(self, prefix: str, key: str):
243252
return key
244253
return f"{prefix}{self.sep}{key}"
245254

246-
def _get_secret_value(self, prefix: str, key: str) -> str | None:
255+
def _build_team_secret_name(self, prefix: str, team_name: str, key: str) -> str:
256+
team_prefix = self._build_secret_name(prefix, team_name)
257+
return f"{team_prefix}{self.sep * TEAM_SEP_MULTIPLIER}{key}"
258+
259+
def _is_team_specific_accessed_as_global(self, secret_id: str, team_name: str | None = None) -> bool:
260+
team_sep = re.escape(self.sep * TEAM_SEP_MULTIPLIER)
261+
return team_name is None and bool(re.fullmatch(rf"[^{re.escape(self.sep)}]+{team_sep}.+", secret_id))
262+
263+
def _get_secret_value(self, prefix: str, key: str, team_name: str | None = None) -> str | None:
264+
secrets = self._get_secrets()
247265
secret: secret_pb.Secret | None = None
248-
for s in self._get_secrets():
249-
if s.name == self._build_secret_name(prefix=prefix, key=key):
250-
secret = s
251-
break
266+
if team_name:
267+
secret = self._find_secret(secrets, prefix, self._build_team_secret_name("", team_name, key))
268+
269+
if not secret:
270+
secret = self._find_secret(secrets, prefix, key)
271+
252272
if not secret:
253273
return None
254274

@@ -259,6 +279,12 @@ def _get_secret_value(self, prefix: str, key: str) -> str | None:
259279
return None
260280
return sorted(entries.values())[0]
261281

282+
def _find_secret(self, secrets: list[secret_pb.Secret], prefix: str, key: str) -> secret_pb.Secret | None:
283+
for s in secrets:
284+
if s.name == self._build_secret_name(prefix=prefix, key=key):
285+
return s
286+
return None
287+
262288
def _get_secrets(self) -> list[secret_pb.Secret]:
263289
# generate client if not exists, to load folder_id from connections
264290
_ = self._client

providers/yandex/tests/unit/yandex/secrets/test_lockbox.py

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from __future__ import annotations
1818

1919
import json
20-
from unittest.mock import MagicMock, Mock, patch
20+
from unittest.mock import ANY, MagicMock, Mock, patch
2121

2222
import pytest
2323

@@ -261,6 +261,64 @@ def test_yandex_lockbox_secret_backend__build_secret_name_custom_sep(self):
261261

262262
assert res == expected
263263

264+
@patch("airflow.providers.yandex.secrets.lockbox.LockboxSecretBackend._get_secrets")
265+
@patch("airflow.providers.yandex.secrets.lockbox.LockboxSecretBackend._get_payload")
266+
def test_get_conn_value_uses_team_specific_secret_first(self, mock_get_payload, mock_get_secrets):
267+
mock_get_secrets.return_value = [
268+
secret_pb.Secret(
269+
id="123",
270+
name="airflow/connections/team_a//my_db",
271+
),
272+
secret_pb.Secret(
273+
id="456",
274+
name="airflow/connections/my_db",
275+
),
276+
]
277+
mock_get_payload.return_value = payload_pb.Payload(
278+
entries=[payload_pb.Payload.Entry(text_value="team-conn")]
279+
)
280+
281+
backend = LockboxSecretBackend()
282+
result = backend.get_conn_value("my_db", team_name="team_a")
283+
284+
assert result == "team-conn"
285+
mock_get_payload.assert_called_once_with("123", ANY)
286+
287+
@patch("airflow.providers.yandex.secrets.lockbox.LockboxSecretBackend._get_secrets")
288+
@patch("airflow.providers.yandex.secrets.lockbox.LockboxSecretBackend._get_payload")
289+
def test_get_variable_falls_back_to_global_secret_when_team_secret_is_missing(
290+
self, mock_get_payload, mock_get_secrets
291+
):
292+
mock_get_secrets.return_value = [
293+
secret_pb.Secret(
294+
id="456",
295+
name="airflow/variables/hello",
296+
),
297+
]
298+
mock_get_payload.return_value = payload_pb.Payload(
299+
entries=[payload_pb.Payload.Entry(text_value="global-value")]
300+
)
301+
302+
backend = LockboxSecretBackend()
303+
result = backend.get_variable("hello", team_name="team_a")
304+
305+
assert result == "global-value"
306+
mock_get_payload.assert_called_once_with("456", ANY)
307+
308+
@patch("airflow.providers.yandex.secrets.lockbox.LockboxSecretBackend._get_secrets")
309+
def test_get_variable_returns_none_for_team_scoped_key_without_team_name(self, mock_get_secrets):
310+
backend = LockboxSecretBackend()
311+
312+
assert backend.get_variable("teama//hello") is None
313+
mock_get_secrets.assert_not_called()
314+
315+
@patch("airflow.providers.yandex.secrets.lockbox.LockboxSecretBackend._get_secrets")
316+
def test_get_conn_value_returns_none_for_team_scoped_id_without_team_name(self, mock_get_secrets):
317+
backend = LockboxSecretBackend()
318+
319+
assert backend.get_conn_value("teama//my_db") is None
320+
mock_get_secrets.assert_not_called()
321+
264322
@patch("airflow.providers.yandex.secrets.lockbox.LockboxSecretBackend._get_secrets")
265323
@patch("airflow.providers.yandex.secrets.lockbox.LockboxSecretBackend._get_payload")
266324
def test_yandex_lockbox_secret_backend__get_secret_value(self, mock_get_payload, mock_get_secrets):

0 commit comments

Comments
 (0)