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
75 changes: 72 additions & 3 deletions infrahub_sdk/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]:
Expand All @@ -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")
Expand Down
42 changes: 38 additions & 4 deletions tests/unit/sdk/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Loading