potiuk commented on code in PR #70869:
URL: https://github.com/apache/airflow/pull/70869#discussion_r3694168634
##########
providers/google/src/airflow/providers/google/cloud/secrets/secret_manager.py:
##########
@@ -37,6 +38,10 @@
SECRET_ID_PATTERN = r"^[a-zA-Z0-9-_]*$"
+# Separator between the team name and the secret id in a team scoped secret
name.
+# Matches the convention the other secrets backends use.
+TEAM_SEP = "--"
Review Comment:
Agreed. There's no shared constant in core to import yet -- Azure and Amazon
each define their own `TEAM_SEP`, and Yandex derives it from `sep` -- so this
stays local for now. Worth consolidating the moment one lands in core.
---
Drafted-by: Claude Code (Opus 5); reviewed by @potiuk before posting
##########
providers/google/src/airflow/providers/google/cloud/secrets/secret_manager.py:
##########
@@ -193,12 +198,52 @@ def get_config(self, key: str) -> str | None:
return self._get_secret(self.config_prefix, key)
- 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)
Review Comment:
Fixed -- the `.replace()` is gone and the secret id is used verbatim.
Both halves of this were right. I reproduced the collision exactly as you
wrote it, and the naming inconsistency was the other side of the same coin.
`_build_team_secret_name` now builds `<prefix><sep><team>--<secret_id>` with
the id untouched, so the team separator can only ever originate from the team
name, and underscores survive in the team-scoped name just as they do in the
team-agnostic one.
Two tests cover it: `test_team_secret_name_is_built_literally` pins the
literal string rather than deriving it from the method under test, and
`test_underscores_cannot_manufacture_a_team_namespace` asserts your
`prod__conn` case returns `None`. The latter fails against the revision you
reviewed.
For the record, the same normalisation is still live in the Azure backend.
#70876 closes it there -- but at the entry point rather than in the builder,
since Azure normalises everywhere by design and the team-scoped name has to
match how the secret is actually stored.
---
Drafted-by: Claude Code (Opus 5); reviewed by @potiuk before posting
##########
providers/google/src/airflow/providers/google/cloud/secrets/secret_manager.py:
##########
@@ -193,12 +198,52 @@ def get_config(self, key: str) -> str | None:
return self._get_secret(self.config_prefix, key)
- 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 _names_a_team_namespace(self, secret_id: str) -> bool:
+ """
+ Whether ``secret_id`` spells out a team scoped secret name.
+
+ A team scoped secret is named ``<prefix><sep><team><TEAM_SEP><secret
id>``, so an id
+ that already contains the team separator makes the team agnostic
lookup resolve a
+ secret inside some team's namespace. That lookup is refused for such
an id.
+
+ The id is never parsed to work out *which* team it names, because it
cannot be: a
+ team name may itself contain the separator, so nothing in the string
distinguishes
+ team ``a`` with id ``b--c`` from team ``a--b`` with id ``c``.
Comparing the id against
+ the prefix the caller's own team builds looks equivalent and is not --
a caller in
+ team ``a`` would match ``a--b``'s namespace on the prefix and read its
secrets. Only
+ the caller's own namespace is ever constructed, never parsed.
+ """
+ normalized_secret_id = self.build_path("", secret_id, self.sep)
Review Comment:
Fixed -- the guard matches the raw id now (`TEAM_SEP in secret_id`) and
doesn't call `build_path` at all.
Your read of the inheritance is correct: this backend uses the base
implementation, so `build_path('', 'smtp_default')` yields `-smtp_default` -- a
stray leading separator and no normalisation, which is why the guard never saw
the `prod__conn` form. With the id no longer normalised when the team-scoped
name is built, there's nothing left for the guard to normalise either, so
matching the raw id is both correct and simpler. I left a note in the docstring
on why routing through `build_path` is wrong here, so it doesn't get copied
back from Azure later.
---
Drafted-by: Claude Code (Opus 5); reviewed by @potiuk before posting
##########
providers/google/tests/unit/google/cloud/secrets/test_secret_manager.py:
##########
@@ -272,3 +272,99 @@ def test_init_succeeds_with_explicit_project_id(self,
mock_client_callable, mock
backend = CloudSecretManagerBackend(project_id="explicit-project")
assert backend.project_id == "explicit-project"
+
+
+class TestCloudSecretManagerBackendTeamScope:
+ """A team scoped secret must only be resolvable for the team it is stored
for."""
+
+ TEAM = "team_a"
+ OTHER_TEAM = "team_b"
+ # A team name may itself contain the team separator, so one team's
namespace can start
+ # with another's. Treating a prefix match as ownership is what this guards
against.
+ EXTENDING_TEAM = "team_a--prod"
+
+ @staticmethod
+ def _backend(mock_client_callable, store):
+ """Back the backend with an in-memory secret store keyed by full
secret name."""
+ client = mock.MagicMock()
+ client.get_secret.side_effect = lambda secret_id, project_id,
**kwargs: store.get(secret_id)
+ mock_client_callable.return_value = client
+ return CloudSecretManagerBackend(
+ connections_prefix=CONNECTIONS_PREFIX,
variables_prefix=VARIABLES_PREFIX, project_id=PROJECT_ID
+ )
+
+ @staticmethod
+ def _team_name(backend, team, secret_id, prefix=CONNECTIONS_PREFIX):
Review Comment:
Fixed. `test_team_secret_name_is_built_literally` now asserts the literal
name:
```
test-connections-team_a--test_postgres
```
plus a second assertion that `smtp_default` keeps its underscore.
Your negative test went in as
`test_underscores_cannot_manufacture_a_team_namespace`. I confirmed it fails
against the revision you reviewed and passes now -- and re-confirmed after the
later refactor by reintroducing the `.replace()` and watching it fail again, so
it isn't passing vacuously.
---
Drafted-by: Claude Code (Opus 5); reviewed by @potiuk before posting
##########
providers/google/src/airflow/providers/google/cloud/secrets/secret_manager.py:
##########
@@ -193,12 +198,52 @@ def get_config(self, key: str) -> str | None:
return self._get_secret(self.config_prefix, key)
- 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 _names_a_team_namespace(self, secret_id: str) -> bool:
+ """
+ Whether ``secret_id`` spells out a team scoped secret name.
+
+ A team scoped secret is named ``<prefix><sep><team><TEAM_SEP><secret
id>``, so an id
+ that already contains the team separator makes the team agnostic
lookup resolve a
+ secret inside some team's namespace. That lookup is refused for such
an id.
+
+ The id is never parsed to work out *which* team it names, because it
cannot be: a
+ team name may itself contain the separator, so nothing in the string
distinguishes
+ team ``a`` with id ``b--c`` from team ``a--b`` with id ``c``.
Comparing the id against
+ the prefix the caller's own team builds looks equivalent and is not --
a caller in
+ team ``a`` would match ``a--b``'s namespace on the prefix and read its
secrets. Only
+ the caller's own namespace is ever constructed, never parsed.
+ """
+ normalized_secret_id = self.build_path("", secret_id, self.sep)
+ return 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 secret value from the SecretManager based on prefix.
:param path_prefix: Prefix for the Path to get Secret
:param secret_id: Secret Key
+ :param team_name: Team the lookup is scoped to (if any)
"""
+ # The team scoped name is tried first and is safe by construction: it
can only ever
+ # build the caller's own namespace.
+ if team_name:
+ team_secret = self.client.get_secret(
Review Comment:
Fixed -- `client = self.client` is bound once at the top of `_get_secret`
and used for both lookups, with a comment recording why, since the cost isn't
visible at the call site.
---
Drafted-by: Claude Code (Opus 5); reviewed by @potiuk before posting
##########
providers/google/tests/unit/google/cloud/secrets/test_secret_manager.py:
##########
@@ -272,3 +272,99 @@ def test_init_succeeds_with_explicit_project_id(self,
mock_client_callable, mock
backend = CloudSecretManagerBackend(project_id="explicit-project")
assert backend.project_id == "explicit-project"
+
+
+class TestCloudSecretManagerBackendTeamScope:
+ """A team scoped secret must only be resolvable for the team it is stored
for."""
+
+ TEAM = "team_a"
+ OTHER_TEAM = "team_b"
+ # A team name may itself contain the team separator, so one team's
namespace can start
+ # with another's. Treating a prefix match as ownership is what this guards
against.
+ EXTENDING_TEAM = "team_a--prod"
+
+ @staticmethod
+ def _backend(mock_client_callable, store):
+ """Back the backend with an in-memory secret store keyed by full
secret name."""
+ client = mock.MagicMock()
+ client.get_secret.side_effect = lambda secret_id, project_id,
**kwargs: store.get(secret_id)
+ mock_client_callable.return_value = client
+ return CloudSecretManagerBackend(
+ connections_prefix=CONNECTIONS_PREFIX,
variables_prefix=VARIABLES_PREFIX, project_id=PROJECT_ID
+ )
+
+ @staticmethod
+ def _team_name(backend, team, secret_id, prefix=CONNECTIONS_PREFIX):
+ return backend._build_team_secret_name(prefix, team, secret_id)
+
+ @mock.patch(MODULE_NAME + ".get_credentials_and_project_id")
+ @mock.patch(MODULE_NAME + "._SecretManagerClient")
+ def test_team_scoped_secret_is_resolved_for_its_own_team(self,
mock_client, mock_get_creds):
+ mock_get_creds.return_value = CREDENTIALS, PROJECT_ID
+ backend = self._backend(mock_client, {})
Review Comment:
Fixed, using your second suggestion -- `_backend` returns `(backend, store)`
and the store is populated after the backend exists, since the lookup closes
over it by reference. The discarded backend is gone from every test in the
class.
---
Drafted-by: Claude Code (Opus 5); reviewed by @potiuk before posting
##########
providers/google/tests/unit/google/cloud/secrets/test_secret_manager.py:
##########
@@ -272,3 +272,99 @@ def test_init_succeeds_with_explicit_project_id(self,
mock_client_callable, mock
backend = CloudSecretManagerBackend(project_id="explicit-project")
assert backend.project_id == "explicit-project"
+
+
+class TestCloudSecretManagerBackendTeamScope:
+ """A team scoped secret must only be resolvable for the team it is stored
for."""
+
+ TEAM = "team_a"
+ OTHER_TEAM = "team_b"
+ # A team name may itself contain the team separator, so one team's
namespace can start
+ # with another's. Treating a prefix match as ownership is what this guards
against.
+ EXTENDING_TEAM = "team_a--prod"
+
+ @staticmethod
+ def _backend(mock_client_callable, store):
+ """Back the backend with an in-memory secret store keyed by full
secret name."""
+ client = mock.MagicMock()
+ client.get_secret.side_effect = lambda secret_id, project_id,
**kwargs: store.get(secret_id)
+ mock_client_callable.return_value = client
+ return CloudSecretManagerBackend(
+ connections_prefix=CONNECTIONS_PREFIX,
variables_prefix=VARIABLES_PREFIX, project_id=PROJECT_ID
+ )
+
+ @staticmethod
+ def _team_name(backend, team, secret_id, prefix=CONNECTIONS_PREFIX):
+ return backend._build_team_secret_name(prefix, team, secret_id)
+
+ @mock.patch(MODULE_NAME + ".get_credentials_and_project_id")
+ @mock.patch(MODULE_NAME + "._SecretManagerClient")
+ def test_team_scoped_secret_is_resolved_for_its_own_team(self,
mock_client, mock_get_creds):
+ mock_get_creds.return_value = CREDENTIALS, PROJECT_ID
+ backend = self._backend(mock_client, {})
+ store = {self._team_name(backend, self.TEAM, CONN_ID): CONN_URI}
+ backend = self._backend(mock_client, store)
+
+ assert backend.get_conn_value(conn_id=CONN_ID, team_name=self.TEAM) ==
CONN_URI
+
+ @mock.patch(MODULE_NAME + ".get_credentials_and_project_id")
+ @mock.patch(MODULE_NAME + "._SecretManagerClient")
+ def test_team_scoped_secret_is_not_resolved_for_another_team(self,
mock_client, mock_get_creds):
+ """The whole point: team B must not reach team A's secret by spelling
out its name."""
+ mock_get_creds.return_value = CREDENTIALS, PROJECT_ID
+ backend = self._backend(mock_client, {})
+ encoded = f"{self.TEAM}--{CONN_ID}"
+ store = {self._team_name(backend, self.TEAM, CONN_ID): CONN_URI}
+ backend = self._backend(mock_client, store)
+
+ assert backend.get_conn_value(conn_id=encoded,
team_name=self.OTHER_TEAM) is None
+
+ @mock.patch(MODULE_NAME + ".get_credentials_and_project_id")
+ @mock.patch(MODULE_NAME + "._SecretManagerClient")
+ def test_team_scoped_secret_is_not_resolved_without_a_team_scope(self,
mock_client, mock_get_creds):
+ mock_get_creds.return_value = CREDENTIALS, PROJECT_ID
+ backend = self._backend(mock_client, {})
+ encoded = f"{self.TEAM}--{CONN_ID}"
+ store = {self._team_name(backend, self.TEAM, CONN_ID): CONN_URI}
+ backend = self._backend(mock_client, store)
+
+ assert backend.get_conn_value(conn_id=encoded) is None
+
+ @mock.patch(MODULE_NAME + ".get_credentials_and_project_id")
+ @mock.patch(MODULE_NAME + "._SecretManagerClient")
+ def test_team_whose_name_extends_the_callers_is_not_readable(self,
mock_client, mock_get_creds):
Review Comment:
Covered, though as a separate test rather than folded into this one --
`test_underscores_cannot_manufacture_a_team_namespace` asserts exactly your
line:
```python
assert backend.get_conn_value(conn_id=f"prod__{CONN_ID}",
team_name=self.TEAM) is None
```
They stay apart because this test is about a team whose *name* extends the
caller's, and that one is about the *id* manufacturing the separator -- two
different mechanisms that happen to reach the same namespace, each worth
failing independently.
---
Drafted-by: Claude Code (Opus 5); reviewed by @potiuk before posting
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]