This is an automated email from the ASF dual-hosted git repository.

potiuk 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 0191b1199ea Refuse the team agnostic fall-through for a team scoped 
AWS secret name (#70878)
0191b1199ea is described below

commit 0191b1199ea08172dd583372b560b7293fa95d43
Author: Jarek Potiuk <[email protected]>
AuthorDate: Sat Aug 1 12:26:04 2026 +0200

    Refuse the team agnostic fall-through for a team scoped AWS secret name 
(#70878)
    
    * Refuse the team agnostic fall-through for a team scoped AWS secret name
    
    The team scoped lookup is tried first and then, on a miss, the lookup falls
    through to the team agnostic name. A guard was meant to stop a caller naming
    another team's secret, but its first condition is `team_name is None`, so it
    does nothing whenever a team scope is supplied -- exactly when it matters. A
    caller authorised for one team could therefore supply an id that spells out
    another team's namespace and resolve that team's secret.
    
    Refuse the fall-through instead. The team scoped lookup still runs first 
and is
    safe by construction, since it can only ever build the caller's own 
namespace.
    After it misses, an id that spells out any team namespace is refused rather 
than
    resolved through the team agnostic name.
    
    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 distinguishes one 
team's
    namespace from another whose name extends it. Comparing the id against the
    prefix the caller's own team builds looks equivalent and is not -- a caller 
in
    team `a` matches `a--b`'s namespace on the prefix and would read its 
secrets.
    
    Generated-by: Claude Opus 5 (1M context) following the guidelines at
    
https://github.com/apache/airflow/blob/main/contributing-docs/05_pull_requests.rst#gen-ai-assisted-contributions
    
    * Refuse an ambiguous secret id before any AWS lookup runs
    
    Refusing only the team agnostic fall-through left the team scoped lookup
    open to the same ambiguity it was meant to close: a caller in team 'a'
    asking for 'b--c' builds exactly the name team 'a--b' builds for 'c', and
    that lookup runs first, so the fall-through is never reached.
    
    Deciding this at the entry points instead means the ambiguity is settled
    before a name is built, in Secrets Manager and Parameter Store alike.
    
    * Apply the ambiguous-id refusal to get_config as well
    
    The check used to live in _get_secret, which all three getters share. 
Moving it
    up into get_conn_value and get_variable left get_config reaching _get_secret
    unguarded, so the helper's claim that such an id is refused for every 
lookup was
    no longer true. Not a cross-team read -- get_config never receives a 
team_name --
    but the two getters and the docstring disagreed with the third.
    
    Guard get_config too, and correct the docstring to name the three getters.
    
    _names_a_team_namespace uses no instance state, so it becomes a 
staticmethod.
    
    The refusal-logging test asserted on the rendered sentence. Airflow logs 
through
    structlog, which renders format args into msg before the stdlib record 
exists, so
    record.args is empty and there is no structured payload to assert on; 
assert on
    level, logger and the refused id instead, which is what the test is actually
    about, and extend it to cover get_config.
---
 .../amazon/aws/secrets/secrets_manager.py          | 55 ++++++++++++---
 .../amazon/aws/secrets/systems_manager.py          | 55 ++++++++++++---
 .../amazon/aws/secrets/test_secrets_manager.py     | 66 +++++++++++++++++-
 .../amazon/aws/secrets/test_systems_manager.py     | 78 +++++++++++++++++++++-
 4 files changed, 236 insertions(+), 18 deletions(-)

diff --git 
a/providers/amazon/src/airflow/providers/amazon/aws/secrets/secrets_manager.py 
b/providers/amazon/src/airflow/providers/amazon/aws/secrets/secrets_manager.py
index ea922237424..415fc945910 100644
--- 
a/providers/amazon/src/airflow/providers/amazon/aws/secrets/secrets_manager.py
+++ 
b/providers/amazon/src/airflow/providers/amazon/aws/secrets/secrets_manager.py
@@ -28,6 +28,9 @@ from airflow.providers.amazon.aws.utils import 
trim_none_values
 from airflow.secrets import BaseSecretsBackend
 from airflow.utils.log.logging_mixin import LoggingMixin
 
+# Separator between the team name and the secret id in a team scoped secret 
name.
+TEAM_SEP = "--"
+
 
 class SecretsManagerBackend(BaseSecretsBackend, LoggingMixin):
     """
@@ -207,6 +210,10 @@ class SecretsManagerBackend(BaseSecretsBackend, 
LoggingMixin):
         if self.connections_prefix is None:
             return None
 
+        if self._names_a_team_namespace(conn_id):
+            self._log_refusal("connection", conn_id)
+            return None
+
         secret = self._get_secret(
             self.connections_prefix, conn_id, self.connections_lookup_pattern, 
team_name
         )
@@ -239,6 +246,10 @@ class SecretsManagerBackend(BaseSecretsBackend, 
LoggingMixin):
         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:
@@ -251,6 +262,10 @@ class SecretsManagerBackend(BaseSecretsBackend, 
LoggingMixin):
         if self.config_prefix is None:
             return None
 
+        if self._names_a_team_namespace(key):
+            self._log_refusal("configuration option", key)
+            return None
+
         return self._get_secret(self.config_prefix, key, 
self.config_lookup_pattern)
 
     def _get_secret_value(self, secret_id: str, secrets_path: str) -> str | 
None:
@@ -303,6 +318,35 @@ class SecretsManagerBackend(BaseSecretsBackend, 
LoggingMixin):
             )
             return None
 
+    @staticmethod
+    def _names_a_team_namespace(secret_id: str) -> bool:
+        """
+        Whether ``secret_id`` spells out a team scoped secret name.
+
+        A team scoped secret is named ``<team><TEAM_SEP><secret id>``, so an 
id that itself
+        contains the team separator makes the built name ambiguous: team ``a`` 
with id ``b--c``
+        and team ``a--b`` with id ``c`` produce the same string. Such an id is 
refused by every
+        getter -- connections, variables and configuration options, team 
scoped as well as team
+        agnostic -- because the ambiguity exists in both directions and the 
caller's own
+        namespace is not a safe harbour for it.
+
+        The id is never parsed to work out *which* team it names, because it 
cannot be: nothing
+        in the string distinguishes the two readings above. 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.
+        """
+        return TEAM_SEP in secret_id
+
+    def _log_refusal(self, kind: str, secret_id: str) -> None:
+        self.log.warning(
+            "%s id %r contains %r, which separates the team name from the 
secret id in a team "
+            "scoped secret name. Such an id is ambiguous and is not looked up. 
Returning None.",
+            kind.capitalize(),
+            secret_id,
+            TEAM_SEP,
+        )
+
     def _get_secret(
         self, path_prefix, secret_id: str, lookup_pattern: str | None, 
team_name: str | None = None
     ) -> str | None:
@@ -322,15 +366,10 @@ class SecretsManagerBackend(BaseSecretsBackend, 
LoggingMixin):
                 lookup_pattern,
             )
             return None
-        if team_name is None and re.fullmatch(r"[^-]+--.+", secret_id):
-            self.log.warning(
-                "Secret ID %r contains a '--' separator, which is reserved for 
team-scoped lookups, "
-                "but no team context was provided. Returning None.",
-                secret_id,
-            )
-            return None
+        # The team scoped name is tried first. Ids that would make it name a 
namespace other
+        # than the caller's own are refused by the callers before reaching 
here.
         if path_prefix and team_name:
-            secrets_path = self.build_path(path_prefix, 
f"{team_name}--{secret_id}", self.sep)
+            secrets_path = self.build_path(path_prefix, 
f"{team_name}{TEAM_SEP}{secret_id}", self.sep)
             value = self._get_secret_value(secret_id, secrets_path)
             if value is not None:
                 return value
diff --git 
a/providers/amazon/src/airflow/providers/amazon/aws/secrets/systems_manager.py 
b/providers/amazon/src/airflow/providers/amazon/aws/secrets/systems_manager.py
index ed99d5f6ccd..5cae97670b7 100644
--- 
a/providers/amazon/src/airflow/providers/amazon/aws/secrets/systems_manager.py
+++ 
b/providers/amazon/src/airflow/providers/amazon/aws/secrets/systems_manager.py
@@ -26,6 +26,9 @@ from airflow.providers.amazon.aws.utils import 
trim_none_values
 from airflow.secrets import BaseSecretsBackend
 from airflow.utils.log.logging_mixin import LoggingMixin
 
+# Separator between the team name and the secret id in a team scoped secret 
name.
+TEAM_SEP = "--"
+
 
 class SystemsManagerParameterStoreBackend(BaseSecretsBackend, LoggingMixin):
     """
@@ -142,6 +145,10 @@ class 
SystemsManagerParameterStoreBackend(BaseSecretsBackend, LoggingMixin):
         if self.connections_prefix is None:
             return None
 
+        if self._names_a_team_namespace(conn_id):
+            self._log_refusal("connection", conn_id)
+            return None
+
         return self._get_secret(self.connections_prefix, conn_id, 
self.connections_lookup_pattern, team_name)
 
     def get_variable(self, key: str, team_name: str | None = None) -> str | 
None:
@@ -155,6 +162,10 @@ class 
SystemsManagerParameterStoreBackend(BaseSecretsBackend, LoggingMixin):
         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:
@@ -167,6 +178,10 @@ class 
SystemsManagerParameterStoreBackend(BaseSecretsBackend, LoggingMixin):
         if self.config_prefix is None:
             return None
 
+        if self._names_a_team_namespace(key):
+            self._log_refusal("configuration option", key)
+            return None
+
         return self._get_secret(self.config_prefix, key, 
self.config_lookup_pattern)
 
     def _get_parameter_value(self, ssm_path: str) -> str | None:
@@ -182,6 +197,35 @@ class 
SystemsManagerParameterStoreBackend(BaseSecretsBackend, LoggingMixin):
             self.log.debug("Parameter %s not found.", ssm_path)
             return None
 
+    @staticmethod
+    def _names_a_team_namespace(secret_id: str) -> bool:
+        """
+        Whether ``secret_id`` spells out a team scoped secret name.
+
+        A team scoped secret is named ``<team><TEAM_SEP><secret id>``, so an 
id that itself
+        contains the team separator makes the built name ambiguous: team ``a`` 
with id ``b--c``
+        and team ``a--b`` with id ``c`` produce the same string. Such an id is 
refused by every
+        getter -- connections, variables and configuration options, team 
scoped as well as team
+        agnostic -- because the ambiguity exists in both directions and the 
caller's own
+        namespace is not a safe harbour for it.
+
+        The id is never parsed to work out *which* team it names, because it 
cannot be: nothing
+        in the string distinguishes the two readings above. 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.
+        """
+        return TEAM_SEP in secret_id
+
+    def _log_refusal(self, kind: str, secret_id: str) -> None:
+        self.log.warning(
+            "%s id %r contains %r, which separates the team name from the 
secret id in a team "
+            "scoped secret name. Such an id is ambiguous and is not looked up. 
Returning None.",
+            kind.capitalize(),
+            secret_id,
+            TEAM_SEP,
+        )
+
     def _get_secret(
         self, path_prefix: str, secret_id: str, lookup_pattern: str | None, 
team_name: str | None = None
     ) -> str | None:
@@ -201,15 +245,10 @@ class 
SystemsManagerParameterStoreBackend(BaseSecretsBackend, LoggingMixin):
                 lookup_pattern,
             )
             return None
-        if team_name is None and re.fullmatch(r"[^-]+--.+", secret_id):
-            self.log.warning(
-                "Secret ID %r contains a '--' separator, which is reserved for 
team-scoped lookups, "
-                "but no team context was provided. Returning None.",
-                secret_id,
-            )
-            return None
+        # The team scoped name is tried first. Ids that would make it name a 
namespace other
+        # than the caller's own are refused by the callers before reaching 
here.
         if team_name:
-            ssm_path = self.build_path(path_prefix, 
f"{team_name}--{secret_id}")
+            ssm_path = self.build_path(path_prefix, 
f"{team_name}{TEAM_SEP}{secret_id}")
             ssm_path = self._ensure_leading_slash(ssm_path)
             value = self._get_parameter_value(ssm_path)
             if value is not None:
diff --git 
a/providers/amazon/tests/unit/amazon/aws/secrets/test_secrets_manager.py 
b/providers/amazon/tests/unit/amazon/aws/secrets/test_secrets_manager.py
index 8eb7ec49cb9..5df7ee72d93 100644
--- a/providers/amazon/tests/unit/amazon/aws/secrets/test_secrets_manager.py
+++ b/providers/amazon/tests/unit/amazon/aws/secrets/test_secrets_manager.py
@@ -16,12 +16,13 @@
 # under the License.
 from __future__ import annotations
 
+import logging
 from unittest import mock
 
 import pytest
 from moto import mock_aws
 
-from airflow.providers.amazon.aws.secrets.secrets_manager import 
SecretsManagerBackend
+from airflow.providers.amazon.aws.secrets.secrets_manager import TEAM_SEP, 
SecretsManagerBackend
 
 
 class TestSecretsManagerBackend:
@@ -92,6 +93,69 @@ class TestSecretsManagerBackend:
 
         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.
+
+        Asserted on the record's structured ``args`` rather than the rendered 
message, so
+        rewording the warning does not silently stop this from testing 
anything.
+        """
+        backend = SecretsManagerBackend()
+
+        assert backend.get_conn_value(conn_id="prod--test_postgres") is None
+        assert backend.get_variable(key="prod--hello") is None
+        assert backend.get_config(key="prod--sql_alchemy_conn") is None
+
+        # Airflow logs through structlog, which renders the format args into 
``msg`` before the
+        # stdlib record is built -- ``record.args`` is empty, so there is no 
structured payload
+        # to assert on. Assert on level, logger and the refused id (the 
load-bearing data)
+        # rather than the wording, so rephrasing the warning does not break 
this.
+        refusals = [
+            r
+            for r in caplog.records
+            if r.levelno == logging.WARNING and 
r.name.endswith(type(backend).__name__)
+        ]
+        assert len(refusals) == 3
+        for refused_id in ("prod--test_postgres", "prod--hello", 
"prod--sql_alchemy_conn"):
+            assert sum(refused_id in r.msg for r in refusals) == 1
+        assert all(TEAM_SEP in r.msg for r in refusals)
+
     @mock_aws
     def test_team_caller_falls_back_to_global_connection(self):
         secret_id = "airflow/connections/test_postgres"
diff --git 
a/providers/amazon/tests/unit/amazon/aws/secrets/test_systems_manager.py 
b/providers/amazon/tests/unit/amazon/aws/secrets/test_systems_manager.py
index 2c318151f4e..ae1719a570b 100644
--- a/providers/amazon/tests/unit/amazon/aws/secrets/test_systems_manager.py
+++ b/providers/amazon/tests/unit/amazon/aws/secrets/test_systems_manager.py
@@ -17,13 +17,17 @@
 from __future__ import annotations
 
 import json
+import logging
 from unittest import mock
 
 import pytest
 from moto import mock_aws
 
 from airflow.configuration import initialize_secrets_backends
-from airflow.providers.amazon.aws.secrets.systems_manager import 
SystemsManagerParameterStoreBackend
+from airflow.providers.amazon.aws.secrets.systems_manager import (
+    TEAM_SEP,
+    SystemsManagerParameterStoreBackend,
+)
 
 from tests_common.test_utils.config import conf_vars
 
@@ -123,6 +127,78 @@ class TestSsmSecrets:
         ssm_backend.client.put_parameter(**param)
         assert ssm_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 parameter 
by naming it."""
+        param = {
+            "Name": "/airflow/connections/my_team--test_postgres",
+            "Type": "String",
+            "Value": "postgresql://airflow:airflow@host:5432/airflow",
+        }
+        ssm_backend = SystemsManagerParameterStoreBackend()
+        ssm_backend.client.put_parameter(**param)
+
+        assert ssm_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."""
+        param = {
+            "Name": "/airflow/connections/my_team--prod--test_postgres",
+            "Type": "String",
+            "Value": "postgresql://airflow:airflow@host:5432/airflow",
+        }
+        ssm_backend = SystemsManagerParameterStoreBackend()
+        ssm_backend.client.put_parameter(**param)
+
+        assert 
ssm_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 
path team
+        ``my_team--prod`` builds for ``test_postgres``, so the team scoped 
lookup *hits*
+        another team's parameter. Refusing only the team agnostic fall-through 
leaves this
+        open, because the fall-through is never reached.
+        """
+        param = {
+            "Name": "/airflow/connections/my_team--prod--test_postgres",
+            "Type": "String",
+            "Value": "postgresql://airflow:airflow@host:5432/airflow",
+        }
+        ssm_backend = SystemsManagerParameterStoreBackend()
+        ssm_backend.client.put_parameter(**param)
+
+        assert ssm_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.
+
+        Asserted on the record's structured ``args`` rather than the rendered 
message, so
+        rewording the warning does not silently stop this from testing 
anything.
+        """
+        backend = SystemsManagerParameterStoreBackend()
+
+        assert backend.get_conn_value(conn_id="prod--test_postgres") is None
+        assert backend.get_variable(key="prod--hello") is None
+        assert backend.get_config(key="prod--sql_alchemy_conn") is None
+
+        # Airflow logs through structlog, which renders the format args into 
``msg`` before the
+        # stdlib record is built -- ``record.args`` is empty, so there is no 
structured payload
+        # to assert on. Assert on level, logger and the refused id (the 
load-bearing data)
+        # rather than the wording, so rephrasing the warning does not break 
this.
+        refusals = [
+            r
+            for r in caplog.records
+            if r.levelno == logging.WARNING and 
r.name.endswith(type(backend).__name__)
+        ]
+        assert len(refusals) == 3
+        for refused_id in ("prod--test_postgres", "prod--hello", 
"prod--sql_alchemy_conn"):
+            assert sum(refused_id in r.msg for r in refusals) == 1
+        assert all(TEAM_SEP in r.msg for r in refusals)
+
     @mock_aws
     def test_team_caller_falls_back_to_global_connection(self):
         param = {

Reply via email to