Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs-website/docs/concepts/secret-management.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ Note that this type of code cannot be serialized, meaning you can't convert the

### Environment Variable-Based Secrets

Environment variable-based secrets are more flexible. They allow you to specify one or more environment variables that may contain your secret.
Environment variable-based secrets are more flexible. They allow you to specify one or more environment variables that may contain your secret. Empty or whitespace-only values are treated as unset, so a blank first candidate does not block fallback to later variables.

Existing Haystack components that require an API Key (like OpenAIChatGenerator) have a default value for `Secret.from_env_var` (in this case, `OPENAI_API_KEY`). This means that the `OpenAIChatGenerator` will look for the value of the environment variable `OPENAI_API_KEY` (if it exists) and use it for authentication. And when pipelines are serialized to YAML, only the name of the environment variable is save to the YAML file. In doing so, this method ensures that there are no security leaks and is therefore strongly recommended.

Expand Down
12 changes: 8 additions & 4 deletions haystack/utils/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,14 +63,16 @@ def from_env_var(env_vars: str | list[str], *, strict: bool = True) -> "Secret":
"""
Create an environment variable-based secret. Accepts one or more environment variables.

Upon resolution, it returns a string token from the first environment variable that is set.
Upon resolution, it returns a string token from the first environment variable that is set
to a non-empty value. Empty or whitespace-only values are treated as unset so they do not
block fallback to later candidates.

:param env_vars:
A single environment variable or an ordered list of
candidate environment variables.
:param strict:
Whether to raise an exception if none of the environment
variables are set.
variables are set to a non-empty value.
"""
if isinstance(env_vars, str):
env_vars = [env_vars]
Expand Down Expand Up @@ -181,7 +183,9 @@ class EnvVarSecret(Secret):
"""
A secret that accepts one or more environment variables.

Upon resolution, it returns a string token from the first environment variable that is set. Can be serialized.
Upon resolution, it returns a string token from the first environment variable
that is set to a non-empty value. Empty or whitespace-only values are treated
as unset. Can be serialized.
"""

_env_vars: tuple[str, ...]
Expand All @@ -207,7 +211,7 @@ def resolve_value(self) -> Any | None:
out = None
for env_var in self._env_vars:
value = os.getenv(env_var)
if value is not None:
if value is not None and value.strip():
out = value
break
if out is None and self._strict:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
fixes:
- |
``Secret.from_env_var`` now treats empty or whitespace-only environment
variable values as unset. A blank first candidate no longer blocks fallback
to later variables, and a strict secret whose only candidates are blank
raises the same ``ValueError`` as a missing variable.
19 changes: 19 additions & 0 deletions test/utils/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,25 @@ def test_env_var_secret():
secret._type = SecretType.TOKEN # type: ignore[misc]


def test_env_var_secret_treats_blank_values_as_unset(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("TEST_ENV_VAR_BLANK", "")
monkeypatch.setenv("TEST_ENV_VAR_SPACES", " ")
monkeypatch.setenv("TEST_ENV_VAR_FALLBACK", "real-token")

blank_strict = Secret.from_env_var("TEST_ENV_VAR_BLANK", strict=True)
with pytest.raises(ValueError, match="None of the following .* variables are set"):
blank_strict.resolve_value()

blank_optional = Secret.from_env_var("TEST_ENV_VAR_BLANK", strict=False)
assert blank_optional.resolve_value() is None

whitespace_optional = Secret.from_env_var("TEST_ENV_VAR_SPACES", strict=False)
assert whitespace_optional.resolve_value() is None

fallback = Secret.from_env_var(["TEST_ENV_VAR_BLANK", "TEST_ENV_VAR_FALLBACK"], strict=True)
assert fallback.resolve_value() == "real-token"


def test_deserialize_secrets_inplace_deserializes_listed_keys():
data = {"api_key": Secret.from_env_var("TEST_ENV_VAR1").to_dict(), "model": "gpt"}

Expand Down