diff --git a/infrahub_sdk/config.py b/infrahub_sdk/config.py index 9a27df82..8096214d 100644 --- a/infrahub_sdk/config.py +++ b/infrahub_sdk/config.py @@ -5,7 +5,7 @@ from typing import Any from pydantic import Field, PrivateAttr, field_validator, model_validator -from pydantic_settings import BaseSettings, SettingsConfigDict +from pydantic_settings import BaseSettings, InitSettingsSource, PydanticBaseSettingsSource, SettingsConfigDict from typing_extensions import Self from .constants import InfrahubClientMode @@ -81,6 +81,39 @@ class ConfigBase(BaseSettings): tls_ca_file: str | None = Field(default=None, description="File path to CA cert or bundle in PEM format") _ssl_context: ssl.SSLContext | None = PrivateAttr(default=None) + @classmethod + def settings_customise_sources( + cls, + settings_cls: type[BaseSettings], + init_settings: PydanticBaseSettingsSource, + env_settings: PydanticBaseSettingsSource, + dotenv_settings: PydanticBaseSettingsSource, + file_secret_settings: PydanticBaseSettingsSource, + ) -> tuple[PydanticBaseSettingsSource, ...]: + """ + Customize settings sources to track which fields were explicitly provided. + This allows us to properly handle authentication method precedence. + """ + + class TrackingInitSource(InitSettingsSource): + """Wrapper around InitSettingsSource that tracks explicitly provided fields.""" + + def __call__(self) -> dict[str, Any]: + init_data = super().__call__() + # Store which fields were explicitly provided in constructor + if init_data: + return {**init_data, "_explicit_fields": set(init_data.keys())} + return init_data + + # Create tracking wrapper with proper initialization + tracking_init = TrackingInitSource( + settings_cls=settings_cls, + init_kwargs=init_settings.init_kwargs if hasattr(init_settings, "init_kwargs") else {}, + ) + + # Return sources in priority order: explicit init values, env vars, dotenv, file secrets + return tracking_init, env_settings, dotenv_settings, file_secret_settings + @model_validator(mode="before") @classmethod def validate_credentials_input(cls, values: dict[str, Any]) -> dict[str, Any]: @@ -105,8 +138,44 @@ def set_transport(cls, values: dict[str, Any]) -> dict[str, Any]: @model_validator(mode="before") @classmethod def validate_mix_authentication_schemes(cls, values: dict[str, Any]) -> dict[str, Any]: - if values.get("password") and values.get("api_token"): - raise ValueError("Unable to combine password with token based authentication") + """ + Handle conflicts between token and password authentication methods. + + When both methods are present (from explicit args or environment variables), + we prioritize the explicitly provided method. If we can determine which fields + were explicitly set, we use that; otherwise, we prefer password auth when both + username and password are present. + """ + # Extract tracking information about explicitly provided fields + explicit_fields = values.pop("_explicit_fields", set()) + + has_password = values.get("password") and values.get("username") + has_token = values.get("api_token") + + # If both auth methods are present, decide which to use + if has_password and has_token: + # Check if one method was explicitly provided + token_explicit = "api_token" in explicit_fields + password_explicit = "username" in explicit_fields or "password" in explicit_fields + + if token_explicit and not password_explicit: + # User explicitly provided token, password came from env - use token + values["username"] = None + values["password"] = None + elif password_explicit and not token_explicit: + # User explicitly provided password, token came from env - use password + values["api_token"] = None + elif token_explicit and password_explicit: + # Both explicitly provided - prefer password auth (consistent behavior) + values["api_token"] = None + else: + # Both from environment or neither explicit - prefer password auth + values["api_token"] = None + elif has_token and not has_password: + # Only token auth present - clear any partial password credentials + values["username"] = None + values["password"] = None + return values @field_validator("address") diff --git a/tests/unit/sdk/test_config.py b/tests/unit/sdk/test_config.py index bc7b538d..ead3aa3c 100644 --- a/tests/unit/sdk/test_config.py +++ b/tests/unit/sdk/test_config.py @@ -5,10 +5,13 @@ def test_combine_authentications() -> None: - with pytest.raises(ValidationError) as exc: - Config(api_token="testing", username="test", password="testpassword") - - assert "Unable to combine password with token based authentication" in str(exc.value) + # When both username/password and api_token are provided, + # password authentication takes precedence and api_token is cleared + config = Config(api_token="testing", username="test", password="testpassword") + assert config.username == "test" + assert config.password == "testpassword" + assert config.api_token is None + assert config.password_authentication is True def test_missing_password() -> None: @@ -39,3 +42,34 @@ def test_config_address() -> None: config = Config(address=address) assert config.address == address + + +def test_password_auth_overrides_env_token(monkeypatch) -> None: + """Test that explicit username/password overrides INFRAHUB_API_TOKEN from environment""" + # Set environment variable for api_token + monkeypatch.setenv("INFRAHUB_API_TOKEN", "token-from-env") + + # Create config with explicit username/password + config = Config(address="https://sandbox.infrahub.app", username="testuser", password="testpass") + + # Password auth should be active and api_token should be cleared + assert config.username == "testuser" + assert config.password == "testpass" + assert config.api_token is None + assert config.password_authentication is True + + +def test_token_auth_overrides_env_password(monkeypatch) -> None: + """Test that explicit api_token overrides INFRAHUB_USERNAME and INFRAHUB_PASSWORD from environment""" + # Set environment variables for username/password + monkeypatch.setenv("INFRAHUB_USERNAME", "user-from-env") + monkeypatch.setenv("INFRAHUB_PASSWORD", "pass-from-env") + + # Create config with explicit api_token + config = Config(address="https://sandbox.infrahub.app", api_token="explicit-token") + + # Token auth should be active and username/password should be cleared + assert config.api_token == "explicit-token" + assert config.username is None + assert config.password is None + assert config.password_authentication is False