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"