diff --git a/CHANGELOG.md b/CHANGELOG.md index adabae8c..f92fbdbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## [v5.2.1] - 2026-10-14 + +### Fixed + +- **Configuration label isolation.** Use the explicit null-label selector + instead of the wildcard so UI-specific secret references are not selected + by the orchestrator; existing service-label precedence is preserved. + +### Added + +- **Opt-in configuration TLS compatibility.** `APP_CONFIG_TLS_COMPATIBILITY=true` + enables verified P-256/TCP MSS 1200 transports only for the App Configuration + endpoint and the Key Vault supplied by `KEY_VAULT_URI`; the default SDK + transport, authentication and certificate validation remain unchanged. + ## [v5.2.0] - 2026-10-06 ### Removed diff --git a/VERSION b/VERSION index ac14c3df..26d99a28 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.1.1 +5.2.1 diff --git a/azure.yaml b/azure.yaml index a13ddea0..9a1d5db1 100644 --- a/azure.yaml +++ b/azure.yaml @@ -1,6 +1,6 @@ -name: azure-gpt-rag +name: agent-landing-zone metadata: - template: azure-gpt-rag-orchestrator + template: agent-app-orchestrator infra: provider: bicep path: infra diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f29216b4..61a0bd1e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,11 +1,11 @@ { - "name": "gpt-rag-orchestrator-dashboard", + "name": "agent-app-orchestrator-dashboard", "version": "0.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "gpt-rag-orchestrator-dashboard", + "name": "agent-app-orchestrator-dashboard", "version": "0.0.0", "dependencies": { "@azure/msal-browser": "^4.21.0", diff --git a/frontend/package.json b/frontend/package.json index a99f6fef..b3ad9ec2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,5 +1,5 @@ { - "name": "gpt-rag-orchestrator-dashboard", + "name": "agent-app-orchestrator-dashboard", "private": true, "version": "0.0.0", "type": "module", diff --git a/frontend/src/components/OverviewTab.tsx b/frontend/src/components/OverviewTab.tsx index c13aefb0..8e4e6fc1 100644 --- a/frontend/src/components/OverviewTab.tsx +++ b/frontend/src/components/OverviewTab.tsx @@ -23,7 +23,7 @@ import { import { clampToToday, rangeLabel, type OverviewRange } from "./overview/range"; import { OVERVIEW_TOOLTIPS } from "./overview/copy"; -const STORAGE_KEY = "gpt-rag-orchestrator.overview.range"; +const STORAGE_KEY = "agent-app-orchestrator.overview.range"; const DEFAULT_RANGE: OverviewRange = { preset: "30d" }; function readStoredRange(): OverviewRange { diff --git a/src/api/hosted_entrypoint.py b/src/api/hosted_entrypoint.py index a0751300..745eb8bf 100644 --- a/src/api/hosted_entrypoint.py +++ b/src/api/hosted_entrypoint.py @@ -984,7 +984,7 @@ def create_app( hosted_app = HostedResponsesAgentServerHost( options=ResponsesServerOptions( - additional_server_version=f"gpt-rag-orchestrator/{_APP_VERSION}", + additional_server_version=f"agent-app-orchestrator/{_APP_VERSION}", ), store=store, routes=[ diff --git a/src/connectors/appconfig.py b/src/connectors/appconfig.py index 8b138acb..a9221660 100644 --- a/src/connectors/appconfig.py +++ b/src/connectors/appconfig.py @@ -1,35 +1,138 @@ import os import logging +import socket +import ssl +from builtins import type as ValueType +from collections.abc import Callable, Mapping, Sequence from typing import Any +from urllib.parse import urlsplit + +import requests +from requests.adapters import HTTPAdapter +from urllib3.connection import HTTPConnection +from azure.core.credentials import TokenCredential +from azure.core.credentials_async import AsyncTokenCredential from azure.core.exceptions import ClientAuthenticationError +from azure.core.pipeline.transport import RequestsTransport from azure.appconfiguration import ( AzureAppConfigurationClient, ConfigurationSetting, ) from azure.appconfiguration.provider import ( AzureAppConfigurationKeyVaultOptions, + AzureAppConfigurationProvider, load, SettingSelector, ) from connectors.identity_manager import get_identity_manager -from tenacity import retry, wait_random_exponential, stop_after_attempt, RetryError +from tenacity import retry, wait_random_exponential, stop_after_attempt, RetryError, RetryCallState -def _with_legacy_app_label(extra_selector): - """Return a provider loader that also reads the legacy app label after the current one.""" - def _load(*args, selects, **kwargs): +def _tls_context(cafile: str | None = None) -> ssl.SSLContext: + context = ( + ssl.create_default_context(cafile=cafile) + if cafile is not None + else ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ) + context.minimum_version = ssl.TLSVersion.TLSv1_2 + context.set_ecdh_curve("prime256v1") + return context + + +class _TLSCompatibilityAdapter(HTTPAdapter): + def __init__(self) -> None: + self.tls_context = _tls_context(requests.certs.where()) + super().__init__() + + def init_poolmanager( + self, connections: int, maxsize: int, block: bool = False, **kwargs: Any + ) -> None: + kwargs["socket_options"] = [ + *HTTPConnection.default_socket_options, + (socket.IPPROTO_TCP, socket.TCP_MAXSEG, 1200), + ] + super().init_poolmanager(connections, maxsize, block, **kwargs) + + def build_connection_pool_key_attributes( + self, + request: requests.PreparedRequest, + verify: bool | str, + cert: str | tuple[str, str] | None = None, + ) -> tuple[dict[str, Any], dict[str, Any]]: + if verify is False: + raise ValueError("Configuration transport requires certificate verification.") + host, options = super().build_connection_pool_key_attributes(request, verify, cert) + # A custom CA must not inherit the default bundle or another caller's roots. + options["ssl_context"] = _tls_context() if isinstance(verify, str) else self.tls_context + options["socket_options"] = self.poolmanager.connection_pool_kw["socket_options"] + return host, options + + def send( + self, + request: requests.PreparedRequest, + stream: bool = False, + timeout: float | tuple[float | None, float | None] | None = None, + verify: bool | str = True, + cert: str | tuple[str, str] | None = None, + proxies: dict[str, str] | None = None, + ) -> requests.Response: + try: + return super().send( + request, stream=stream, timeout=timeout, verify=verify, cert=cert, + proxies=proxies, + ) + except requests.RequestException as exc: + logging.getLogger(__name__).error( + "Configuration TLS compatibility request failed: error_type=%s", + ValueType(exc).__name__, + ) + raise + + +def _create_appconfig_transport(endpoint: str) -> RequestsTransport: + parsed = urlsplit(endpoint) + if ( + parsed.scheme != "https" + or not parsed.hostname + or parsed.username is not None + or parsed.password is not None + or parsed.path not in ("", "/") + or parsed.query + or parsed.fragment + ): + raise ValueError("Configuration transport requires a plain HTTPS endpoint.") + session = requests.Session() + session.mount(f"https://{parsed.netloc}/", _TLSCompatibilityAdapter()) + return RequestsTransport(session=session, session_owner=True) + + +def _with_legacy_app_label( + extra_selector: SettingSelector, + *, + transport: RequestsTransport | None = None, + key_vault_client_configs: Mapping[str, Mapping[str, Any]] | None = None, +) -> Callable[..., AzureAppConfigurationProvider]: + """Adapt provider selectors and optional bootstrap transport without replacing credentials.""" + def _load( + *args: Any, selects: Sequence[SettingSelector], **kwargs: Any + ) -> AzureAppConfigurationProvider: merged = list(selects) merged.insert(min(2, len(merged)), extra_selector) + if transport is not None: + kwargs["transport"] = transport + if key_vault_client_configs is not None: + kwargs["key_vault_options"].client_configs = key_vault_client_configs return load(*args, selects=merged, **kwargs) return _load + class AppConfigClient: - credential = None - aiocredential = None + credential: TokenCredential | None = None + aiocredential: AsyncTokenCredential | None = None - def __init__(self): + def __init__(self) -> None: """ Bulk-loads all keys into an in-memory dict from the most common labels used by Agent Landing Zone: - 'orchestrator' (legacy / shared deployments) @@ -42,7 +145,7 @@ def __init__(self): # Defaults self.disabled = False self.auth_failed = False - self.client = {} + self.client: Mapping[str, Any] = {} # ==== Load all config parameters in one place ==== # NOTE: Do not default to "*" for client_id. @@ -77,8 +180,30 @@ def __init__(self): orchestrator_label_selector = SettingSelector(label_filter=APP_LABEL, key_filter='*') legacy_app_label_selector = SettingSelector(label_filter=LEGACY_APP_LABEL, key_filter='*') base_label_selector = SettingSelector(label_filter=AGENTLZ_LABEL, key_filter='*') - no_label_selector = SettingSelector(label_filter=None, key_filter='*') - load = _with_legacy_app_label(legacy_app_label_selector) + no_label_selector = SettingSelector(label_filter="\0", key_filter='*') + + tls_compatibility = os.getenv("APP_CONFIG_TLS_COMPATIBILITY", "false").strip().lower() + if tls_compatibility not in ("true", "false"): + raise ValueError("APP_CONFIG_TLS_COMPATIBILITY must be true or false.") + transport = None + key_vault_client_configs = None + if tls_compatibility == "true": + vault_endpoint = os.getenv("KEY_VAULT_URI", "").strip() + if not vault_endpoint: + raise ValueError("APP_CONFIG_TLS_COMPATIBILITY requires KEY_VAULT_URI.") + vault_transport = _create_appconfig_transport(vault_endpoint) + transport = _create_appconfig_transport(endpoint) + key_vault_client_configs = { + vault_endpoint.rstrip("/") + "/": {"transport": vault_transport} + } + logging.info( + "Configuration TLS compatibility is enabled for App Configuration " + "and the configured Key Vault; certificate verification remains enabled." + ) + load = _with_legacy_app_label( + legacy_app_label_selector, transport=transport, + key_vault_client_configs=key_vault_client_configs, + ) logging.info( "Azure App Configuration init: endpoint_host=%s identity=%s allow_env_vars=%s labels=%s", @@ -119,15 +244,15 @@ def __init__(self): self.client = {} - def get(self, key: str, default: Any = None, type: type = str) -> Any: + def get(self, key: str, default: Any = None, type: ValueType[Any] | None = str) -> Any: return self.get_value(key, default=default, allow_none=False, type=type) - - def get_value(self, key: str, default: str = None, allow_none: bool = False, type: type = str) -> str: + + def get_value(self, key: str, default: Any = None, allow_none: bool = False, type: ValueType[Any] | None = str) -> Any: if key is None: raise Exception('The key parameter is required for get_value().') - value = None + value: Any = None allow_env_vars = False if "allow_environment_variables" in os.environ: @@ -162,16 +287,16 @@ def get_value(self, key: str, default: str = None, allow_none: bool = False, typ else: if default is not None or allow_none is True: return default - + raise Exception(f'The configuration variable {key} not found.') - + @staticmethod - def retry_before_sleep(retry_state): + def retry_before_sleep(retry_state: RetryCallState) -> None: # Log the outcome of each retry attempt. message = f"""Retrying {retry_state.fn}: attempt {retry_state.attempt_number} ended with: {retry_state.outcome}""" - if retry_state.outcome.failed: + if retry_state.outcome is not None and retry_state.outcome.failed: ex = retry_state.outcome.exception() message += f"; Exception: {ex.__class__.__name__}: {ex}" if retry_state.attempt_number < 1: @@ -184,7 +309,7 @@ def retry_before_sleep(retry_state): stop=stop_after_attempt(5), before_sleep=retry_before_sleep ) - def get_config_with_retry(self, name): + def get_config_with_retry(self, name: str) -> Any: if self.disabled or not self.client: return None try: @@ -193,15 +318,15 @@ def get_config_with_retry(self, name): return None # Helper functions for reading environment variables - def read_env_variable(self, var_name, default=None): + def read_env_variable(self, var_name: str, default: str | None = None) -> str | None: value = self.get_value(var_name, default) return value.strip() if value else default - def read_env_list(self, var_name): + def read_env_list(self, var_name: str) -> list[str]: value = self.get_value(var_name, "") return [item.strip() for item in value.split(",") if item.strip()] - def read_env_boolean(self, var_name, default=False): + def read_env_boolean(self, var_name: str, default: bool = False) -> bool: value = self.get_value(var_name, str(default)).strip().lower() return value in ['true', '1', 'yes'] @@ -236,6 +361,9 @@ def set_value(self, key: str, value: Any, label: str = "agent-app-orchestrator") if not endpoint: raise RuntimeError("APP_CONFIG_ENDPOINT is not set.") + if self.credential is None: + raise RuntimeError("Azure App Configuration credentials are not initialized.") + if isinstance(value, bool): str_value = "true" if value else "false" else: diff --git a/src/constants.py b/src/constants.py index f2f30c3c..58ea9174 100644 --- a/src/constants.py +++ b/src/constants.py @@ -1,2 +1,2 @@ APPLICATION_INSIGHTS_CONNECTION_STRING = "APPLICATIONINSIGHTS_CONNECTION_STRING" -APP_NAME = "gpt-rag-orchestrator" \ No newline at end of file +APP_NAME = "agent-app-orchestrator" \ No newline at end of file diff --git a/tests/test_appconfig_agentlz_single_read.py b/tests/test_appconfig_agentlz_single_read.py index b5c6ecec..f9ae63fa 100644 --- a/tests/test_appconfig_agentlz_single_read.py +++ b/tests/test_appconfig_agentlz_single_read.py @@ -6,6 +6,8 @@ from pathlib import Path import pytest +import requests +from azure.core.exceptions import ClientAuthenticationError import constants from connectors.appconfig import AppConfigClient, candidate_keys @@ -24,7 +26,93 @@ def _client(monkeypatch, mock_identity_manager, values, env_enabled=False): def test_label_selectors_read_agent_lz_only(monkeypatch, mock_identity_manager): _, load = _client(monkeypatch, mock_identity_manager, {}) labels = [s.label_filter for s in load.call_args.kwargs["selects"]] - assert labels == ["orchestrator", "agent-app-orchestrator", "gpt-rag-orchestrator", "agent-lz", None] + assert labels == ["orchestrator", "agent-app-orchestrator", "gpt-rag-orchestrator", "agent-lz", "\0"] + + +def test_default_transport_does_not_require_a_key_vault_uri(monkeypatch, mock_identity_manager): + monkeypatch.delenv("APP_CONFIG_TLS_COMPATIBILITY", raising=False) + monkeypatch.delenv("KEY_VAULT_URI", raising=False) + with patch("connectors.appconfig._create_appconfig_transport") as transport: + _, load = _client(monkeypatch, mock_identity_manager, {}) + transport.assert_not_called() + assert "transport" not in load.call_args.kwargs + + +def test_opt_in_uses_verified_transports_for_both_endpoints(monkeypatch, mock_identity_manager): + monkeypatch.setenv("APP_CONFIG_TLS_COMPATIBILITY", "true") + monkeypatch.setenv("KEY_VAULT_URI", "https://vault.example.invalid/") + with patch("connectors.appconfig._create_appconfig_transport") as transport: + _, load = _client(monkeypatch, mock_identity_manager, {}) + assert [call.args[0] for call in transport.call_args_list] == [ + "https://vault.example.invalid/", + "https://config.example.invalid", + ] + assert load.call_args.kwargs["transport"] is transport.return_value + vault_options = load.call_args.kwargs["key_vault_options"] + assert vault_options.client_configs == { + "https://vault.example.invalid/": {"transport": transport.return_value} + } + assert vault_options.credential is mock_identity_manager.get_credential.return_value + + +@pytest.mark.parametrize("value", ["", "yes", "typo"]) +def test_invalid_transport_option_fails_before_loading(monkeypatch, mock_identity_manager, value): + monkeypatch.setenv("APP_CONFIG_TLS_COMPATIBILITY", value) + monkeypatch.setenv("APP_CONFIG_ENDPOINT", "https://config.example.invalid") + with ( + patch("connectors.appconfig.get_identity_manager", return_value=mock_identity_manager), + patch("connectors.appconfig.load") as load, + pytest.raises(ValueError, match="must be true or false"), + ): + AppConfigClient() + load.assert_not_called() + + +def test_opt_in_requires_explicit_key_vault_uri(monkeypatch, mock_identity_manager): + monkeypatch.setenv("APP_CONFIG_TLS_COMPATIBILITY", "true") + monkeypatch.delenv("KEY_VAULT_URI", raising=False) + monkeypatch.setenv("APP_CONFIG_ENDPOINT", "https://config.example.invalid") + with ( + patch("connectors.appconfig.get_identity_manager", return_value=mock_identity_manager), + patch("connectors.appconfig.load") as load, + pytest.raises(ValueError, match="requires KEY_VAULT_URI"), + ): + AppConfigClient() + load.assert_not_called() + + +@pytest.mark.parametrize("compatibility", ["false", "true"]) +@pytest.mark.parametrize("error_type", [ClientAuthenticationError, requests.ReadTimeout]) +def test_transport_profile_preserves_provider_failure_classification( + monkeypatch, mock_identity_manager, caplog, compatibility, error_type +): + monkeypatch.setenv("APP_CONFIG_ENDPOINT", "https://config.example.invalid") + monkeypatch.setenv("APP_CONFIG_TLS_COMPATIBILITY", compatibility) + monkeypatch.setenv("KEY_VAULT_URI", "https://vault.example.invalid/") + monkeypatch.setenv("allow_environment_variables", "false") + with ( + patch("connectors.appconfig.get_identity_manager", return_value=mock_identity_manager), + patch("connectors.appconfig._create_appconfig_transport"), + patch("connectors.appconfig.load", side_effect=error_type("private-error-marker")), + ): + cfg = AppConfigClient() + assert cfg.disabled + assert cfg.auth_failed is (error_type is ClientAuthenticationError) + assert cfg.client == {} + assert "private-error-marker" not in caplog.text + with pytest.raises(Exception, match="AGENTLZ_REQUIRED not found"): + cfg.get("AGENTLZ_REQUIRED") + + +def test_write_rejects_uninitialized_credentials(monkeypatch, mock_identity_manager): + cfg, _ = _client(monkeypatch, mock_identity_manager, {}) + cfg.credential = None + with ( + patch("connectors.appconfig.AzureAppConfigurationClient") as write_client, + pytest.raises(RuntimeError, match="credentials are not initialized"), + ): + cfg.set_value("AGENTLZ_TEST", "value") + write_client.assert_not_called() @pytest.mark.parametrize("key", ["AGENTLZ_FOO", "GPT_RAG_FOO", "SEARCH_SERVICE_NAME"]) @@ -69,4 +157,4 @@ def test_telemetry_service_name_uses_agentlz_prefix(): assert len(calls) == 1 service_name = calls[0].args[2] assert isinstance(service_name, ast.Constant) and service_name.value == "agentlz.orchestrator" - assert constants.APP_NAME == "gpt-rag-orchestrator" \ No newline at end of file + assert constants.APP_NAME == "agent-app-orchestrator" \ No newline at end of file diff --git a/tests/test_appconfig_transport.py b/tests/test_appconfig_transport.py new file mode 100644 index 00000000..c76d9eb8 --- /dev/null +++ b/tests/test_appconfig_transport.py @@ -0,0 +1,109 @@ +"""Configuration TLS compatibility is explicit, verified and endpoint-scoped.""" + +import socket +import ssl +from unittest.mock import patch + +import pytest +import requests +from requests.adapters import HTTPAdapter +from urllib3.connection import HTTPConnection + +from connectors.appconfig import _create_appconfig_transport as create_appconfig_transport + + +ENDPOINT = "https://config.example.invalid" + + +def test_compatibility_is_scoped_to_the_configured_https_origin(): + transport = create_appconfig_transport(ENDPOINT) + adapter = transport.session.get_adapter(ENDPOINT + "/kv") + other = transport.session.get_adapter("https://another.example.invalid/kv") + prefix_collision = transport.session.get_adapter(ENDPOINT + ".attacker.invalid/kv") + try: + assert type(adapter) is not HTTPAdapter + assert type(other) is HTTPAdapter + assert type(prefix_collision) is HTTPAdapter + finally: + transport.close() + + +def test_tls_verification_and_existing_socket_options_are_preserved(): + transport = create_appconfig_transport(ENDPOINT) + adapter = transport.session.get_adapter(ENDPOINT + "/kv") + request = requests.Request("GET", ENDPOINT + "/kv").prepare() + try: + _, options = adapter.build_connection_pool_key_attributes(request, verify=True) + context = options["ssl_context"] + assert context.verify_mode == ssl.CERT_REQUIRED + assert context.check_hostname + assert context.minimum_version == ssl.TLSVersion.TLSv1_2 + assert context.maximum_version == ssl.TLSVersion.MAXIMUM_SUPPORTED + for option in HTTPConnection.default_socket_options: + assert option in options["socket_options"] + assert (socket.IPPROTO_TCP, socket.TCP_MAXSEG, 1200) in options["socket_options"] + finally: + transport.close() + + +def test_custom_ca_bundle_is_preserved(): + transport = create_appconfig_transport(ENDPOINT) + adapter = transport.session.get_adapter(ENDPOINT + "/kv") + request = requests.Request("GET", ENDPOINT + "/kv").prepare() + try: + _, options = adapter.build_connection_pool_key_attributes( + request, verify="custom-ca.pem" + ) + assert options["ca_certs"] == "custom-ca.pem" + assert options["cert_reqs"] == "CERT_REQUIRED" + assert options["ssl_context"].cert_store_stats()["x509_ca"] == 0 + _, other_options = adapter.build_connection_pool_key_attributes( + request, verify="another-custom-ca.pem" + ) + assert other_options["ssl_context"] is not options["ssl_context"] + finally: + transport.close() + + +def test_disabling_certificate_verification_is_rejected(): + transport = create_appconfig_transport(ENDPOINT) + adapter = transport.session.get_adapter(ENDPOINT + "/kv") + request = requests.Request("GET", ENDPOINT + "/kv").prepare() + try: + with pytest.raises(ValueError, match="certificate verification"): + adapter.build_connection_pool_key_attributes(request, verify=False) + finally: + transport.close() + + +@pytest.mark.parametrize( + "endpoint", + [ + "http://config.example.invalid", + "https://user:password@config.example.invalid", + "https://config.example.invalid/kv", + "https://config.example.invalid?credential=private", + "https://config.example.invalid/#fragment", + "https://", + ], +) +def test_unsafe_or_non_root_endpoints_are_rejected(endpoint): + with pytest.raises(ValueError, match="plain HTTPS endpoint"): + create_appconfig_transport(endpoint) + + +def test_transport_failures_propagate_without_sensitive_logging(caplog): + transport = create_appconfig_transport(ENDPOINT) + adapter = transport.session.get_adapter(ENDPOINT + "/kv") + request = requests.Request("GET", ENDPOINT + "/kv?private-query-marker=1").prepare() + error = requests.ReadTimeout("private-exception-marker") + try: + with patch.object(HTTPAdapter, "send", side_effect=error): + with pytest.raises(requests.ReadTimeout) as caught: + adapter.send(request, timeout=5, verify=True) + assert caught.value is error + assert "ReadTimeout" in caplog.text + assert "private-query-marker" not in caplog.text + assert "private-exception-marker" not in caplog.text + finally: + transport.close()