shahar1 commented on code in PR #70878:
URL: https://github.com/apache/airflow/pull/70878#discussion_r3694826106
##########
providers/amazon/src/airflow/providers/amazon/aws/secrets/secrets_manager.py:
##########
@@ -239,6 +246,10 @@ def get_variable(self, key: str, team_name: str | None =
None) -> str | None:
if self.variables_prefix is None:
return None
+ if self._names_a_team_namespace(key):
+ self._log_refusal("variable", key)
+ return None
+
return self._get_secret(self.variables_prefix, key,
self.variables_lookup_pattern, team_name)
def get_config(self, key: str) -> str | None:
Review Comment:
`get_config` silently loses the guard. It was previously covered because the
check lived in `_get_secret`, which all three getters share; now that the check
has moved up into `get_conn_value` and `get_variable`, `get_config` reaches
`_get_secret` unguarded, so `get_config("a--b")` resolves `airflow/config/a--b`
where it used to return `None` with a warning.
There is no cross-team read here — `get_config` takes no `team_name`, so a
team scoped name is never built for it — and arguably config keys should never
have been caught by the old guard in the first place. But as it stands the new
`_names_a_team_namespace` docstring asserts such an id is *"refused for
**every** lookup"*, which is no longer true, and the loosening isn't mentioned
in the PR's "Behaviour change" section.
Either add the check to `get_config` too, or keep the exemption and say so
in both the docstring and the PR description.
##########
providers/amazon/src/airflow/providers/amazon/aws/secrets/secrets_manager.py:
##########
@@ -303,6 +314,33 @@ def _get_secret_value(self, secret_id: str, secrets_path:
str) -> str | None:
)
return None
+ def _names_a_team_namespace(self, secret_id: str) -> bool:
Review Comment:
Two small things on this helper:
1. The docstring says such an id is refused for *"**every** lookup -- team
scoped as well as team agnostic"*. That is true for connections and variables,
but `get_config` calls `_get_secret` without going through this check, so
config lookups are exempt (see the comment on `get_config`). Worth reconciling
the claim with the behaviour in whichever direction you pick.
2. It doesn't use `self` — `@staticmethod` fits, and matches core's
equivalent `EnvironmentVariablesBackend._is_team_specific_accessed_as_global`,
which is already a `@staticmethod`.
##########
providers/amazon/tests/unit/amazon/aws/secrets/test_secrets_manager.py:
##########
@@ -92,6 +92,54 @@ def
test_global_caller_cannot_access_team_scoped_connection(self):
assert
secrets_manager_backend.get_conn_value(conn_id="my_team--test_postgres") is None
+ @mock_aws
+ def test_another_teams_secret_is_not_reachable(self):
+ """A caller scoped to one team must not reach another team's secret by
naming it."""
+ secret_id = "airflow/connections/my_team--test_postgres"
+ create_param = {"Name": secret_id, "SecretString":
"postgresql://airflow:airflow@host:5432/airflow"}
+ backend = SecretsManagerBackend()
+ backend.client.create_secret(**create_param)
+
+ assert backend.get_conn_value(conn_id="my_team--test_postgres",
team_name="other_team") is None
+
+ @mock_aws
+ def test_team_whose_name_extends_the_callers_is_not_reachable(self):
+ """A prefix match on the caller's own namespace is not proof of
ownership."""
+ secret_id = "airflow/connections/my_team--prod--test_postgres"
+ create_param = {"Name": secret_id, "SecretString":
"postgresql://airflow:airflow@host:5432/airflow"}
+ backend = SecretsManagerBackend()
+ backend.client.create_secret(**create_param)
+
+ assert backend.get_conn_value(conn_id="my_team--prod--test_postgres",
team_name="my_team") is None
+
+ @mock_aws
+ def test_team_scoped_lookup_cannot_reach_a_longer_teams_namespace(self):
+ """The team scoped name is not safe by construction -- the id can
extend it.
+
+ Team ``my_team`` asking for ``prod--test_postgres`` builds exactly the
name team
+ ``my_team--prod`` builds for ``test_postgres``, so the team scoped
lookup *hits*
+ another team's secret. Refusing only the team agnostic fall-through
leaves this
+ open, because the fall-through is never reached.
+ """
+ secret_id = "airflow/connections/my_team--prod--test_postgres"
+ create_param = {"Name": secret_id, "SecretString":
"postgresql://airflow:airflow@host:5432/airflow"}
+ backend = SecretsManagerBackend()
+ backend.client.create_secret(**create_param)
+
+ assert backend.get_conn_value(conn_id="prod--test_postgres",
team_name="my_team") is None
+
+ @mock_aws
+ def test_refusing_an_ambiguous_id_is_logged(self, caplog):
+ """A silent ``None`` is indistinguishable from a missing secret, so
the refusal is logged."""
+ backend = SecretsManagerBackend()
+
+ assert backend.get_conn_value(conn_id="prod--test_postgres") is None
+ assert backend.get_variable(key="prod--hello") is None
+
+ refusals = [r for r in caplog.records if "is ambiguous and is not
looked up" in r.getMessage()]
Review Comment:
This matches on the rendered log message, which the project's testing
standard steers away from:
> Do not assert on raw log text (`caplog.text` for example), these are
legacy string matching APIs planned for removal. Structured assertions via
`caplog` (which resolves to `cap_structlog` under structlog) are fine and
preferred: `"event name" in caplog` or `{"event": ..., "field": ...} in caplog`.
Beyond the standard, the coupling is what makes it worth changing: the
assertion pins the exact sentence *"is ambiguous and is not looked up"*, so any
rewording of `_log_refusal` breaks the test for a reason that has nothing to do
with the behaviour under test. A structured assertion on the event plus the
level would survive that.
Testing that the refusal is logged at all is a good call, though — a silent
`None` really is indistinguishable from a missing secret.
##########
providers/amazon/src/airflow/providers/amazon/aws/secrets/systems_manager.py:
##########
@@ -155,6 +162,10 @@ def get_variable(self, key: str, team_name: str | None =
None) -> str | None:
if self.variables_prefix is None:
return None
+ if self._names_a_team_namespace(key):
+ self._log_refusal("variable", key)
+ return None
+
return self._get_secret(self.variables_prefix, key,
self.variables_lookup_pattern, team_name)
def get_config(self, key: str) -> str | None:
Review Comment:
Same as in `secrets_manager.py`: `get_config` still calls `_get_secret`
directly, so it no longer gets the check that used to live there. Not a
cross-team read (no `team_name` is ever passed here), but it means the "refused
for every lookup" claim in `_names_a_team_namespace` doesn't hold, and the
loosening isn't in the PR's "Behaviour change" section.
##########
providers/amazon/src/airflow/providers/amazon/aws/secrets/systems_manager.py:
##########
@@ -182,6 +193,33 @@ def _get_parameter_value(self, ssm_path: str) -> str |
None:
self.log.debug("Parameter %s not found.", ssm_path)
return None
+ def _names_a_team_namespace(self, secret_id: str) -> bool:
Review Comment:
`@staticmethod` here too — no `self` use, and it matches core's
`EnvironmentVariablesBackend._is_team_specific_accessed_as_global`.
Also, this docstring and `_log_refusal` below are byte-identical to the
copies in `secrets_manager.py`. Not a blocker given the two backends already
mirror each other, but the rationale is long enough that one copy will
eventually get updated and the other won't.
--
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]