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 9c483aeab58 Warn when SimpleAuthManager runs in a production-shaped 
deployment (#66563)
9c483aeab58 is described below

commit 9c483aeab586ab4f32d154a392eb681a5637189f
Author: Jarek Potiuk <[email protected]>
AuthorDate: Fri May 8 22:08:03 2026 +0200

    Warn when SimpleAuthManager runs in a production-shaped deployment (#66563)
    
    * Warn loudly when SimpleAuthManager runs in a production-shaped deployment
    
    SimpleAuthManager is dev-only by design: it stores passwords in plaintext
    JSON, prints generated passwords to stdout/logs on first init, and provides
    no rotation mechanism. Documentation says so in the class docstring, but
    nothing prevents an operator from configuring it (or leaving it at default)
    in a production deployment, where the password leak becomes a real exposure.
    
    Add a heuristic check at `init()` time: if any of the following are true,
    the deployment shape suggests production and we emit a `log.warning`:
    
    - The SQL backend is not sqlite (i.e. Postgres or MySQL is configured).
    - The API host is bound to a non-local address.
    - The configured executor is not a 
Local-/Sequential-/Debug-/InProcessExecutor.
    
    None of these are conclusive on their own — a developer can configure any
    combination locally — but the cumulative signal is strong enough that a
    loud warning in the startup log is worth the false-positive cost. The
    warning is non-blocking; it does not refuse to start.
    
    Reported by the L3 ASVS sweep at apache/tooling-agents#23 (FINDING-039).
    
    * fix(simple-auth): make _looks_like_production unit-testable via kwargs
    
    CI failed three test_looks_like_production[*-is-dev] parametrize
    cases plus test_init_does_not_warn_for_dev_shape. Each one set the
    three production-signal axes (sql_alchemy_conn / api.host /
    core.executor) to dev-shape values via conf_vars(...) and expected
    False from the function — but in CI the function returned True,
    i.e. conf.get(...) inside the function did not see the conf_vars
    patch for at least one of the three keys. The same setup returns
    False locally, suggesting a CI-only env-var or breeze-container
    quirk shadowing the conf_vars patch for these specific keys.
    
    Rather than chase the env interaction, decouple the decision logic
    from the global conf so unit tests can exercise it directly:
    
    - _looks_like_production now accepts sql_conn / api_host / executor
      as keyword-only args. None (the default) falls through to
      conf.get(), preserving the production code path. Tests pass the
      three values explicitly and the function never reads conf.
    - test_looks_like_production parametrize cases now pass full
      kwargs triples (so each row stands on its own and is robust to
      CI conf state). One extra case added for the str.strip() path
      on api_host, plus a 127.0.0.1 loopback case alongside localhost.
    - test_init_warns_when_production_shaped and
      test_init_does_not_warn_for_dev_shape now mock
      _looks_like_production directly to control the branch under
      test, then assert log.warning was / was not called. The init
      contract this verifies — "if _looks_like_production() is True,
      log.warning is called once" — is what these tests are really
      about; the conf-reading behaviour is covered by the unit tests
      on the function itself.
    
    Generated-by: Claude Code (Opus 4.7)
---
 .../auth/managers/simple/simple_auth_manager.py    |  57 ++++++++++++
 .../managers/simple/test_simple_auth_manager.py    | 101 +++++++++++++++++++++
 2 files changed, 158 insertions(+)

diff --git 
a/airflow-core/src/airflow/api_fastapi/auth/managers/simple/simple_auth_manager.py
 
b/airflow-core/src/airflow/api_fastapi/auth/managers/simple/simple_auth_manager.py
index ebcd7bc4e4d..6ca1c782a70 100644
--- 
a/airflow-core/src/airflow/api_fastapi/auth/managers/simple/simple_auth_manager.py
+++ 
b/airflow-core/src/airflow/api_fastapi/auth/managers/simple/simple_auth_manager.py
@@ -119,11 +119,68 @@ class 
SimpleAuthManager(BaseAuthManager[SimpleAuthManagerUser]):
         with open(password_file, "r+") as file:
             return SimpleAuthManager._get_passwords(file)
 
+    @staticmethod
+    def _looks_like_production(
+        *,
+        sql_conn: str | None = None,
+        api_host: str | None = None,
+        executor: str | None = None,
+    ) -> bool:
+        """
+        Best-effort heuristic for whether the Airflow deployment looks 
production-shaped.
+
+        Returns True if any of the following hold:
+
+        - The SQL backend is not sqlite (i.e. Postgres or MySQL is configured).
+        - The API host is bound to a non-local address.
+        - The configured executor is not 
Local-/Sequential-/Debug-/InProcessExecutor.
+
+        None of these are *definitive* — a developer can pick any combination 
locally
+        — but the cumulative signal is strong enough to justify a loud warning 
that
+        SimpleAuthManager (which is dev-only by design) is being used in a 
setup
+        that resembles production.
+
+        Each axis can be passed in directly (kwargs) so unit tests can probe 
the
+        decision logic without touching the global ``conf`` state. ``None`` 
(the
+        default) reads the value from ``conf``.
+        """
+        if sql_conn is None:
+            sql_conn = conf.get("database", "sql_alchemy_conn", fallback="")
+        if api_host is None:
+            api_host = conf.get("api", "host", fallback="localhost")
+        if executor is None:
+            executor = conf.get("core", "executor", fallback="LocalExecutor")
+
+        if sql_conn and not sql_conn.startswith("sqlite:"):
+            return True
+        if api_host.strip() not in {"localhost", "127.0.0.1", "::1", "[::1]"}:
+            return True
+        # Split on '.' to get the class name only (handles fully-qualified 
executor paths).
+        local_executors = {"LocalExecutor", "SequentialExecutor", 
"DebugExecutor", "InProcessExecutor"}
+        if executor.split(".")[-1] not in local_executors:
+            return True
+        return False
+
     def init(self) -> None:
         super().init()
         is_simple_auth_manager_all_admins = conf.getboolean("core", 
"simple_auth_manager_all_admins")
         if is_simple_auth_manager_all_admins:
             return
+
+        # SimpleAuthManager is dev-only by design — it stores passwords in 
plaintext,
+        # prints generated passwords to stdout/logs on first init, and 
provides no
+        # rotation mechanism. Emit a loud warning when the deployment shape 
suggests
+        # production so it shows up in startup logs of misconfigured 
deployments.
+        if self._looks_like_production():
+            log.warning(
+                "SimpleAuthManager is active but the deployment shape looks 
like production "
+                "(non-sqlite backend, non-local API host, or a distributed 
executor). "
+                "SimpleAuthManager stores passwords in plaintext at %s and 
prints generated "
+                "passwords to stdout/logs on first init. Use a real auth 
manager "
+                "(e.g. FAB or Keycloak) for production deployments.",
+                self.get_generated_password_file(),
+            )
+
         users = self.get_users()
         password_file = self.get_generated_password_file()
 
diff --git 
a/airflow-core/tests/unit/api_fastapi/auth/managers/simple/test_simple_auth_manager.py
 
b/airflow-core/tests/unit/api_fastapi/auth/managers/simple/test_simple_auth_manager.py
index 75e177fb7a6..7c95342c498 100644
--- 
a/airflow-core/tests/unit/api_fastapi/auth/managers/simple/test_simple_auth_manager.py
+++ 
b/airflow-core/tests/unit/api_fastapi/auth/managers/simple/test_simple_auth_manager.py
@@ -121,6 +121,107 @@ class TestSimpleAuthManager:
             auth_manager.init()
             assert not 
os.path.exists(auth_manager.get_generated_password_file())
 
+    @pytest.mark.parametrize(
+        ("kwargs", "expected"),
+        [
+            pytest.param(
+                {
+                    "sql_conn": "sqlite:////tmp/airflow.db",
+                    "api_host": "localhost",
+                    "executor": "LocalExecutor",
+                },
+                False,
+                id="all-dev",
+            ),
+            pytest.param(
+                {
+                    "sql_conn": "postgresql+psycopg2://airflow@db/airflow",
+                    "api_host": "localhost",
+                    "executor": "LocalExecutor",
+                },
+                True,
+                id="postgres-backend-is-prod-shape",
+            ),
+            pytest.param(
+                {
+                    "sql_conn": "mysql://airflow@db/airflow",
+                    "api_host": "localhost",
+                    "executor": "LocalExecutor",
+                },
+                True,
+                id="mysql-backend-is-prod-shape",
+            ),
+            pytest.param(
+                {
+                    "sql_conn": "sqlite:////tmp/airflow.db",
+                    "api_host": "0.0.0.0",
+                    "executor": "LocalExecutor",
+                },
+                True,
+                id="non-local-bind-is-prod-shape",
+            ),
+            pytest.param(
+                {
+                    "sql_conn": "sqlite:////tmp/airflow.db",
+                    "api_host": "127.0.0.1",
+                    "executor": "LocalExecutor",
+                },
+                False,
+                id="loopback-bind-is-dev",
+            ),
+            pytest.param(
+                {
+                    "sql_conn": "sqlite:////tmp/airflow.db",
+                    "api_host": "localhost",
+                    "executor": "CeleryExecutor",
+                },
+                True,
+                id="celery-executor-is-prod-shape",
+            ),
+            pytest.param(
+                {
+                    "sql_conn": "sqlite:////tmp/airflow.db",
+                    "api_host": "localhost",
+                    "executor": 
"airflow.executors.local_executor.LocalExecutor",
+                },
+                False,
+                id="fully-qualified-local-executor-is-dev",
+            ),
+            pytest.param(
+                {
+                    "sql_conn": "sqlite:////tmp/airflow.db",
+                    "api_host": "  localhost  ",
+                    "executor": "LocalExecutor",
+                },
+                False,
+                id="api-host-is-stripped",
+            ),
+        ],
+    )
+    def test_looks_like_production(self, kwargs, expected):
+        # The function takes each axis as a keyword argument so the test fully
+        # controls the inputs and does not rely on conf state. Any axis left
+        # as None would fall through to conf.get(); we pass all three 
explicitly.
+        assert SimpleAuthManager._looks_like_production(**kwargs) is expected
+
+    @mock.patch.object(SimpleAuthManager, "_looks_like_production", 
return_value=True)
+    
@mock.patch("airflow.api_fastapi.auth.managers.simple.simple_auth_manager.log")
+    def test_init_warns_when_production_shaped(self, mock_log, mock_check, 
auth_manager):
+        """SimpleAuthManager.init() emits a loud warning in a 
production-shaped deployment."""
+        with conf_vars({("core", "simple_auth_manager_users"): "alice:admin"}):
+            auth_manager.init()
+        mock_check.assert_called_once_with()
+        mock_log.warning.assert_called_once()
+
+    @mock.patch.object(SimpleAuthManager, "_looks_like_production", 
return_value=False)
+    
@mock.patch("airflow.api_fastapi.auth.managers.simple.simple_auth_manager.log")
+    def test_init_does_not_warn_for_dev_shape(self, mock_log, mock_check, 
auth_manager):
+        """No production warning when the deployment shape looks like local 
dev."""
+        with conf_vars({("core", "simple_auth_manager_users"): "alice:admin"}):
+            auth_manager.init()
+        mock_check.assert_called_once_with()
+        mock_log.warning.assert_not_called()
+
     def test_get_url_login(self, auth_manager):
         result = auth_manager.get_url_login()
         assert result == AUTH_MANAGER_FASTAPI_APP_PREFIX + "/login"

Reply via email to