From a41d80b7bf1789027bab36ec53643a167b76bfcd Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Fri, 4 Sep 2026 09:53:41 -0400 Subject: [PATCH 1/3] Add HTTP Basic authentication to the Yamcs action for proxied servers Ground segments commonly publish Yamcs through a reverse proxy, such as Apache, that challenges every request with HTTP Basic authentication against a directory before the request reaches Yamcs. Yamcs behind that proxy often has no authentication of its own. The Yamcs action could authenticate to Yamcs but had no way to answer a front-door proxy challenge, so such a server was unreachable even when every Yamcs setting was correct. Adds an optional Reverse Proxy Authentication layer, independent of the Yamcs authentication method and off by default so a directly reachable server, such as a local simulator, is unaffected. Credentials can be entered inline, with the password stored in Key Vault, or supplied by a reusable username/password identity. The proxy credential gets its own identity reference, separate from the Yamcs credential, so one action can use both and a rotating temporary password is maintained once under Workspace > Identities instead of by editing the action. Proxy Basic auth combines with the none and api_key Yamcs methods. It is blocked for username_password and bearer_token: only one Authorization header can be sent, and the Yamcs token exchange would itself be refused by the proxy. The rule is enforced in the plugin, the health checker, the test-connection route, and the action modal, so the conflict surfaces before an agent depends on it. Implementation notes: - yamcs-client 2.1.0 already ships BasicAuthCredentials, so no dependency change is needed. It is imported separately from the other credential classes so a deployment on an older client keeps working for every non-proxy method. - basic_auth_password is registered in YAMCS_SENSITIVE_ADDITIONAL_FIELDS, which routes it through the existing Key Vault store, redact, retrieve, and delete handling. - The proxy identity reference is resolved inside validate_action_identity_reference and hydrate_action_identity_reference, so personal, group, and global action paths pick it up unchanged. UI hydration returns the identity username but never its password. - Turning the toggle off preserves the stored credential rather than blanking it, which would drop the Key Vault reference and orphan the secret. Fixes #1435 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- application/single_app/config.py | 2 +- .../functions_workspace_identities.py | 121 +++- .../single_app/functions_yamcs_operations.py | 64 +- .../single_app/route_backend_plugins.py | 71 ++- .../plugin_health_checker.py | 14 + .../semantic_kernel_plugins/yamcs_plugin.py | 60 +- .../static/js/plugin_modal_stepper.js | 185 +++++- ...mcs_plugin.additional_settings.schema.json | 17 + .../single_app/templates/_plugin_modal.html | 45 ++ docs/explanation/features/YAMCS_ACTION.md | 57 ++ docs/explanation/release-notes/index.md | 87 +++ docs/explanation/release_notes.md | 13 + docs/reference/actions/yamcs.md | 45 ++ functional_tests/test_yamcs_basic_auth.py | 552 ++++++++++++++++++ 14 files changed, 1300 insertions(+), 33 deletions(-) create mode 100644 functional_tests/test_yamcs_basic_auth.py diff --git a/application/single_app/config.py b/application/single_app/config.py index 64cd23643..42be1c9c5 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -97,7 +97,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.261.011" +VERSION = "0.261.012" IS_DEVELOPMENT = is_development_env_enabled() # Opt-out for deployments where App Service Easy Auth is active but the platform diff --git a/application/single_app/functions_workspace_identities.py b/application/single_app/functions_workspace_identities.py index 3a43747dd..955e713dc 100644 --- a/application/single_app/functions_workspace_identities.py +++ b/application/single_app/functions_workspace_identities.py @@ -73,6 +73,12 @@ ACTION_IDENTITY_TABLEAU_AUTH_TYPES = {"api_key", "username_password"} ACTION_IDENTITY_YAMCS_TYPES = {"yamcs"} ACTION_IDENTITY_YAMCS_AUTH_TYPES = {"api_key", "bearer_token", "username_password"} +# Yamcs actions may sit behind a reverse proxy that enforces HTTP Basic authentication. +# That credential is separate from the Yamcs credential, so it gets its own identity +# reference and is always a username/password pair. +ACTION_PROXY_IDENTITY_FIELD = "basic_auth_identity_id" +ACTION_PROXY_IDENTITY_AUTH_TYPES = {"username_password"} +ACTION_PROXY_IDENTITY_TYPES = {"yamcs"} def _now_iso() -> str: @@ -438,7 +444,13 @@ def validate_action_identity_reference( scope_type: str, scope_id: str, ) -> Optional[Dict[str, Any]]: - """Validate that an action references an action-capable identity in its own scope.""" + """Validate that an action references action-capable identities in its own scope. + + Both the primary credential reference and the optional reverse-proxy credential + reference are checked so callers do not need to know which ones an action uses. + """ + validate_action_proxy_identity_reference(action_data, scope_type, scope_id) + identity_id = get_action_identity_reference_id(action_data) if not identity_id: return None @@ -460,6 +472,50 @@ def validate_action_identity_reference( return identity +def get_action_proxy_identity_reference_id(action_data: Dict[str, Any]) -> str: + """Return the reverse-proxy credential identity reference on an action, if present.""" + if not isinstance(action_data, dict): + return "" + + additional_fields = action_data.get("additionalFields") + if not isinstance(additional_fields, dict): + return "" + + return _normalize_text(additional_fields.get(ACTION_PROXY_IDENTITY_FIELD), 255) + + +def validate_action_proxy_identity_reference( + action_data: Dict[str, Any], + scope_type: str, + scope_id: str, +) -> Optional[Dict[str, Any]]: + """Validate an action's reverse-proxy credential identity reference.""" + identity_id = get_action_proxy_identity_reference_id(action_data) + if not identity_id: + return None + + plugin_type = _normalize_text((action_data or {}).get("type"), 80).lower() + if plugin_type not in ACTION_PROXY_IDENTITY_TYPES: + raise ValueError("This action type does not support a proxy credential identity") + + scope_type = _validate_scope(scope_type) + if scope_type == WORKSPACE_IDENTITY_SCOPE_PUBLIC: + raise ValueError("Public workspace identities cannot be used by actions") + + identity = get_workspace_identity(scope_type, scope_id, identity_id) + if not identity_supports_usage( + identity, + "action", + source_type="action", + auth_types=ACTION_PROXY_IDENTITY_AUTH_TYPES, + ): + raise ValueError( + "Selected workspace identity is not a username/password identity configured for action use" + ) + + return identity + + def _get_action_identity_auth_types_for_plugin(action_data: Dict[str, Any]) -> Set[str]: plugin_type = _normalize_text((action_data or {}).get("type"), 80).lower() if plugin_type in ACTION_IDENTITY_SQL_TYPES: @@ -483,10 +539,35 @@ def hydrate_action_identity_reference( scope_id: str, return_type: SecretReturnType = SecretReturnType.TRIGGER, ) -> Dict[str, Any]: - """Apply a referenced workspace identity to an action manifest for UI or runtime use.""" + """Apply referenced workspace identities to an action manifest for UI or runtime use. + + Handles the primary credential reference and the optional reverse-proxy credential + reference independently, so an action may use either, both, or neither. + """ if not isinstance(action_data, dict): return action_data + hydrated_action = _hydrate_primary_action_identity( + action_data, + scope_type, + scope_id, + return_type, + ) + return _hydrate_proxy_action_identity( + hydrated_action, + scope_type, + scope_id, + return_type, + ) + + +def _hydrate_primary_action_identity( + action_data: Dict[str, Any], + scope_type: str, + scope_id: str, + return_type: SecretReturnType, +) -> Dict[str, Any]: + """Apply the primary referenced workspace identity to an action manifest.""" identity_id = get_action_identity_reference_id(action_data) if not identity_id: return action_data @@ -512,6 +593,42 @@ def hydrate_action_identity_reference( return _apply_action_identity_auth(hydrated_action, resolved_auth) +def _hydrate_proxy_action_identity( + action_data: Dict[str, Any], + scope_type: str, + scope_id: str, + return_type: SecretReturnType, +) -> Dict[str, Any]: + """Apply a referenced reverse-proxy credential identity to an action manifest.""" + identity_id = get_action_proxy_identity_reference_id(action_data) + if not identity_id: + return action_data + + identity = validate_action_proxy_identity_reference(action_data, scope_type, scope_id) + hydrated_action = dict(action_data) + additional_fields = dict(hydrated_action.get("additionalFields") or {}) + additional_fields[ACTION_PROXY_IDENTITY_FIELD] = identity_id + additional_fields["basic_auth_identity_auth_type"] = _normalize_text( + (identity.get("auth") or {}).get("auth_type"), 50 + ).lower() + + if return_type == SecretReturnType.TRIGGER: + # The username is safe to echo back so the modal can show which account is used; + # the password stays out of any UI-bound payload. + additional_fields["basic_auth_username"] = _normalize_text( + (identity.get("auth") or {}).get("username"), 255 + ) + additional_fields["basic_auth_password"] = "" + hydrated_action["additionalFields"] = additional_fields + return hydrated_action + + resolved_auth = get_workspace_identity_auth(scope_type, scope_id, identity_id) + additional_fields["basic_auth_username"] = str(resolved_auth.get("username") or "") + additional_fields["basic_auth_password"] = str(resolved_auth.get("password") or "") + hydrated_action["additionalFields"] = additional_fields + return hydrated_action + + def _apply_action_identity_auth(action_data: Dict[str, Any], identity_auth: Dict[str, Any]) -> Dict[str, Any]: """Return a transient action manifest with identity credentials resolved for runtime use.""" action = dict(action_data) diff --git a/application/single_app/functions_yamcs_operations.py b/application/single_app/functions_yamcs_operations.py index f2435706f..1290c209f 100644 --- a/application/single_app/functions_yamcs_operations.py +++ b/application/single_app/functions_yamcs_operations.py @@ -1,6 +1,7 @@ # functions_yamcs_operations.py """Shared defaults and normalization helpers for Yamcs mission control action plugins.""" +import base64 import re from typing import Any, Dict, Optional @@ -22,6 +23,28 @@ } YAMCS_SUPPORTED_AUTH_TYPES = {"NoAuth", "key", "identity", "username_password"} +# Some ground segments front Yamcs with a reverse proxy (commonly Apache) that enforces +# HTTP Basic authentication before the request ever reaches Yamcs. That challenge is a +# separate layer from the Yamcs auth method, so it is configured independently. +YAMCS_BASIC_AUTH_ENABLED_FIELD = "enable_basic_auth" +YAMCS_BASIC_AUTH_USERNAME_FIELD = "basic_auth_username" +YAMCS_BASIC_AUTH_PASSWORD_FIELD = "basic_auth_password" +YAMCS_BASIC_AUTH_IDENTITY_FIELD = "basic_auth_identity_id" +YAMCS_BASIC_AUTH_IDENTITY_AUTH_TYPE_FIELD = "basic_auth_identity_auth_type" + +# Proxy Basic auth occupies the Authorization header. Yamcs username/password and bearer +# token auth also send Authorization, so those cannot be combined. API key auth travels in +# the separate x-api-key header and unauthenticated Yamcs sends nothing, so both are safe. +YAMCS_BASIC_AUTH_COMPATIBLE_AUTH_METHODS = { + YAMCS_AUTH_METHOD_NONE, + YAMCS_AUTH_METHOD_API_KEY, +} +YAMCS_BASIC_AUTH_CONFLICT_MESSAGE = ( + "Yamcs HTTP Basic authentication cannot be combined with username/password or bearer " + "token authentication because both send the HTTP Authorization header. Use 'No " + "Authentication' or 'API Key' for the Yamcs authentication method." +) + # Yamcs archive SQL is a full engine that also supports DDL/DML. Only these leading # keywords are accepted, and only when archive SQL is explicitly enabled. YAMCS_ALLOWED_READ_STATEMENTS = { @@ -32,10 +55,12 @@ } # Secrets always live in auth.key, but the constant keeps redaction plumbing symmetric -# with the other connector action types. +# with the other connector action types. The proxy Basic auth password is stored in +# additionalFields, so listing it here routes it through the same Key Vault handling. YAMCS_SENSITIVE_ADDITIONAL_FIELDS = { "api_key", "access_token", + "basic_auth_password", "password", "token", } @@ -141,6 +166,7 @@ def normalize_yamcs_additional_fields( # with the other connector action types and is always forced on. fields["read_only"] = True fields["enable_archive_sql"] = _as_bool(fields.get("enable_archive_sql"), default=False) + fields.update(normalize_yamcs_basic_auth_fields(fields)) fields["max_rows"] = _as_int( fields.get("max_rows"), YAMCS_DEFAULT_MAX_ROWS, @@ -160,3 +186,39 @@ def normalize_yamcs_additional_fields( YAMCS_MAX_BYTE_LIMIT, ) return fields + + +def normalize_yamcs_basic_auth_fields( + additional_fields: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + """Normalize the reverse-proxy HTTP Basic authentication fields. + + Stored values are preserved when the toggle is off so turning it back on does not + orphan the Key Vault secret that backs the password. + """ + fields = additional_fields if isinstance(additional_fields, dict) else {} + return { + YAMCS_BASIC_AUTH_ENABLED_FIELD: _as_bool( + fields.get(YAMCS_BASIC_AUTH_ENABLED_FIELD), default=False + ), + YAMCS_BASIC_AUTH_USERNAME_FIELD: str( + fields.get(YAMCS_BASIC_AUTH_USERNAME_FIELD) or "" + ).strip(), + YAMCS_BASIC_AUTH_PASSWORD_FIELD: str(fields.get(YAMCS_BASIC_AUTH_PASSWORD_FIELD) or ""), + YAMCS_BASIC_AUTH_IDENTITY_FIELD: str( + fields.get(YAMCS_BASIC_AUTH_IDENTITY_FIELD) or "" + ).strip(), + } + + +def yamcs_basic_auth_conflicts_with_auth_method(auth_method: Any) -> bool: + """Return True when proxy Basic auth cannot coexist with the Yamcs auth method.""" + normalized_method = str(auth_method or "").strip().lower() + return normalized_method not in YAMCS_BASIC_AUTH_COMPATIBLE_AUTH_METHODS + + +def build_yamcs_basic_auth_header(username: Any, password: Any) -> str: + """Build an HTTP Basic ``Authorization`` header value for the Yamcs reverse proxy.""" + credential = f"{str(username or '')}:{str(password or '')}" + encoded = base64.b64encode(credential.encode("utf-8")).decode("ascii") + return f"Basic {encoded}" diff --git a/application/single_app/route_backend_plugins.py b/application/single_app/route_backend_plugins.py index ebfd0350b..a40e5ca5a 100644 --- a/application/single_app/route_backend_plugins.py +++ b/application/single_app/route_backend_plugins.py @@ -113,10 +113,13 @@ YAMCS_AUTH_METHOD_BEARER_TOKEN, YAMCS_AUTH_METHOD_NONE, YAMCS_AUTH_METHOD_USERNAME_PASSWORD, + YAMCS_BASIC_AUTH_CONFLICT_MESSAGE, YAMCS_DEFAULT_PROCESSOR, YAMCS_PLUGIN_TYPE, + build_yamcs_basic_auth_header, normalize_yamcs_additional_fields, normalize_yamcs_server_url, + yamcs_basic_auth_conflicts_with_auth_method, ) from functions_mcp_operations import ( MCP_CUSTOM_HEADERS_FIELD, @@ -791,7 +794,7 @@ def _hydrate_sql_test_identity(data, existing_plugin, user_id): ACTION_CONNECTION_TEST_AUTH_SECRET_FIELDS = ('key', 'identity', 'tenantId') -ACTION_CONNECTION_TEST_ADDITIONAL_SECRET_FIELDS = ('private_key_passphrase',) +ACTION_CONNECTION_TEST_ADDITIONAL_SECRET_FIELDS = ('private_key_passphrase', 'basic_auth_password') # Secret reference sources must match how keyvault_plugin_get_helper stored each field. ACTION_AUTH_SECRET_SOURCES = {"action"} ACTION_ADDITIONAL_SECRET_SOURCES = {"action-addset"} @@ -2611,6 +2614,12 @@ def test_yamcs_connection(): auth_method = (data.get('auth_method') or YAMCS_AUTH_METHOD_USERNAME_PASSWORD).strip().lower() username = (data.get('username') or '').strip() auth_key = (data.get('auth_key') or '').strip() + enable_basic_auth = data.get('enable_basic_auth', False) + if isinstance(enable_basic_auth, str): + enable_basic_auth = enable_basic_auth.strip().lower() in {'1', 'true', 'yes', 'on'} + enable_basic_auth = bool(enable_basic_auth) + basic_auth_username = (data.get('basic_auth_username') or '').strip() + basic_auth_password = data.get('basic_auth_password') or '' tls_verify = data.get('tls_verify', True) if isinstance(tls_verify, str): tls_verify = tls_verify.strip().lower() in {'1', 'true', 'yes', 'on'} @@ -2630,6 +2639,8 @@ def test_yamcs_connection(): 'success': False, 'error': "Yamcs auth_method must be 'username_password', 'api_key', 'bearer_token', or 'none'." }), 400 + if enable_basic_auth and yamcs_basic_auth_conflicts_with_auth_method(auth_method): + return jsonify({'success': False, 'error': YAMCS_BASIC_AUTH_CONFLICT_MESSAGE}), 400 try: existing_plugin = _load_existing_plugin_for_test(data.get('existing_plugin'), user_id) @@ -2676,6 +2687,39 @@ def test_yamcs_connection(): if not username: return jsonify({'success': False, 'error': 'A Yamcs username is required for username/password authentication.'}), 400 + if enable_basic_auth: + existing_additional_fields = {} + if isinstance(existing_plugin, dict) and isinstance(existing_plugin.get('additionalFields'), dict): + existing_additional_fields = existing_plugin['additionalFields'] + + if not basic_auth_username: + basic_auth_username = str(existing_additional_fields.get('basic_auth_username') or '').strip() + if basic_auth_password in ('', ui_trigger_word): + basic_auth_password = existing_additional_fields.get('basic_auth_password') or '' + if basic_auth_password == ui_trigger_word: + return jsonify({ + 'success': False, + 'error': 'Stored Yamcs HTTP Basic authentication password could not be resolved for testing. Re-enter the password.' + }), 400 + + try: + plugin_scope_value, plugin_scope = _resolve_plugin_secret_context(existing_plugin, user_id) + basic_auth_password = _resolve_secret_value_for_action_test( + basic_auth_password, + 'additionalFields.basic_auth_password', + 'Yamcs', + plugin_scope_value, + plugin_scope, + ACTION_ADDITIONAL_SECRET_SOURCES, + ) + except ValueError as exc: + return jsonify({'success': False, 'error': str(exc)}), 400 + + if not basic_auth_username: + return jsonify({'success': False, 'error': 'A username is required for Yamcs HTTP Basic authentication.'}), 400 + if not basic_auth_password: + return jsonify({'success': False, 'error': 'A password is required for Yamcs HTTP Basic authentication.'}), 400 + client = None try: try: @@ -2688,6 +2732,15 @@ def test_yamcs_connection(): if auth_method == YAMCS_AUTH_METHOD_NONE: credentials = None + if enable_basic_auth: + try: + from yamcs.client import BasicAuthCredentials + except ImportError: + return jsonify({ + 'success': False, + 'error': 'Yamcs HTTP Basic authentication requires yamcs-client 1.8.8 or newer on the server.' + }), 400 + credentials = BasicAuthCredentials(basic_auth_username, basic_auth_password) elif auth_method == YAMCS_AUTH_METHOD_API_KEY: credentials = APIKeyCredentials(auth_key) elif auth_method == YAMCS_AUTH_METHOD_BEARER_TOKEN: @@ -2711,6 +2764,13 @@ def request_with_timeout(*args, **kwargs): session.request = request_with_timeout + # API key auth travels in x-api-key, so the Authorization header stays free for + # a reverse proxy. Unauthenticated Yamcs uses BasicAuthCredentials instead. + if enable_basic_auth and auth_method == YAMCS_AUTH_METHOD_API_KEY: + session.headers.update({ + 'Authorization': build_yamcs_basic_auth_header(basic_auth_username, basic_auth_password) + }) + server_info = client.get_server_info() instance_names = [str(getattr(item, 'name', '')) for item in client.list_instances()] if instance not in instance_names: @@ -2751,7 +2811,13 @@ def request_with_timeout(*args, **kwargs): status_code = getattr(getattr(exc, 'response', None), 'status_code', None) raw_message = str(exc) if status_code in (401, 403) or 'unauthorized' in raw_message.lower() or 'forbidden' in raw_message.lower(): - error_msg = 'Yamcs authentication failed. Verify the selected authentication method and credentials.' + if enable_basic_auth: + error_msg = ( + 'Authentication failed. Verify the HTTP Basic authentication username and password ' + 'the reverse proxy expects, and the Yamcs credentials if the server also requires them.' + ) + else: + error_msg = 'Yamcs authentication failed. Verify the selected authentication method and credentials.' status = 403 elif status_code == 404: error_msg = 'The Yamcs server responded, but the requested resource was not found. Verify the server URL.' @@ -2767,6 +2833,7 @@ def request_with_timeout(*args, **kwargs): 'server_url': server_url, 'instance': instance, 'auth_method': auth_method, + 'basic_auth_enabled': enable_basic_auth, 'status_code': status_code, }, level=logging.WARNING, diff --git a/application/single_app/semantic_kernel_plugins/plugin_health_checker.py b/application/single_app/semantic_kernel_plugins/plugin_health_checker.py index 4d7088676..80f20a928 100644 --- a/application/single_app/semantic_kernel_plugins/plugin_health_checker.py +++ b/application/single_app/semantic_kernel_plugins/plugin_health_checker.py @@ -58,6 +58,7 @@ YAMCS_AUTH_METHOD_BEARER_TOKEN, YAMCS_AUTH_METHOD_NONE, YAMCS_AUTH_METHOD_USERNAME_PASSWORD, + YAMCS_BASIC_AUTH_CONFLICT_MESSAGE, YAMCS_MAX_MAX_ROWS, YAMCS_MAX_TIMEOUT, YAMCS_MIN_MAX_ROWS, @@ -67,6 +68,7 @@ YAMCS_SUPPORTED_AUTH_TYPES, normalize_yamcs_additional_fields, normalize_yamcs_server_url, + yamcs_basic_auth_conflicts_with_auth_method, ) from functions_mcp_operations import ( MCP_CUSTOM_HEADERS_FIELD, @@ -354,6 +356,18 @@ def validate_plugin_manifest(manifest: Dict[str, Any], plugin_type: str) -> Tupl elif auth_method == YAMCS_AUTH_METHOD_NONE and auth_type not in {'NoAuth', 'identity'}: errors.append("Yamcs unauthenticated access requires auth.type='NoAuth'") + if additional_fields.get('enable_basic_auth'): + basic_auth_identity_id = str(additional_fields.get('basic_auth_identity_id') or '').strip() + if yamcs_basic_auth_conflicts_with_auth_method(auth_method): + errors.append(YAMCS_BASIC_AUTH_CONFLICT_MESSAGE) + # A referenced identity supplies both values at runtime, so only unreferenced + # configurations must carry an inline username and password. + if not basic_auth_identity_id: + if not additional_fields.get('basic_auth_username'): + errors.append("Yamcs HTTP Basic authentication requires additionalFields.basic_auth_username") + if not additional_fields.get('basic_auth_password'): + errors.append("Yamcs HTTP Basic authentication requires additionalFields.basic_auth_password") + yamcs_range_fields = { 'max_rows': (YAMCS_MIN_MAX_ROWS, YAMCS_MAX_MAX_ROWS), 'timeout': (YAMCS_MIN_TIMEOUT, YAMCS_MAX_TIMEOUT), diff --git a/application/single_app/semantic_kernel_plugins/yamcs_plugin.py b/application/single_app/semantic_kernel_plugins/yamcs_plugin.py index cfdb4766d..8fecb0b56 100644 --- a/application/single_app/semantic_kernel_plugins/yamcs_plugin.py +++ b/application/single_app/semantic_kernel_plugins/yamcs_plugin.py @@ -19,12 +19,15 @@ YAMCS_AUTH_METHOD_BEARER_TOKEN, YAMCS_AUTH_METHOD_NONE, YAMCS_AUTH_METHOD_USERNAME_PASSWORD, + YAMCS_BASIC_AUTH_CONFLICT_MESSAGE, YAMCS_DEFAULT_PROCESSOR, YAMCS_PLUGIN_TYPE, YAMCS_SUPPORTED_AUTH_METHODS, YAMCS_SUPPORTED_AUTH_TYPES, + build_yamcs_basic_auth_header, normalize_yamcs_additional_fields, normalize_yamcs_server_url, + yamcs_basic_auth_conflicts_with_auth_method, ) from semantic_kernel_plugins.base_plugin import BasePlugin from semantic_kernel_plugins.plugin_invocation_logger import plugin_function_logger @@ -68,6 +71,9 @@ def __init__(self, manifest: Optional[Dict[str, Any]] = None): self.auth_method = self._additional_fields.get("auth_method") or YAMCS_AUTH_METHOD_USERNAME_PASSWORD self.tls_verify = bool(self._additional_fields.get("tls_verify", True)) self.enable_archive_sql = bool(self._additional_fields.get("enable_archive_sql", False)) + self.enable_basic_auth = bool(self._additional_fields.get("enable_basic_auth", False)) + self.basic_auth_username = str(self._additional_fields.get("basic_auth_username") or "") + self.basic_auth_password = str(self._additional_fields.get("basic_auth_password") or "") self.max_rows = int(self._additional_fields.get("max_rows") or 500) self.timeout = int(self._additional_fields.get("timeout") or 30) self.byte_limit = int(self._additional_fields.get("byte_limit") or 250000) @@ -234,6 +240,7 @@ def _validate_configuration(self) -> None: raise ValueError( "Yamcs action supports auth methods username_password, api_key, bearer_token, or none." ) + self._validate_basic_auth_configuration() if self.auth_type == "identity": if not (self._auth.get("identity") or self.manifest.get("identity_id")): raise ValueError("Yamcs reusable identity auth requires auth.identity or identity_id.") @@ -247,10 +254,26 @@ def _validate_configuration(self) -> None: if not self._auth.get("key"): raise ValueError("Yamcs API key and bearer token auth require auth.key.") + def _validate_basic_auth_configuration(self) -> None: + """Validate the optional reverse-proxy HTTP Basic authentication layer.""" + if not self.enable_basic_auth: + return + if yamcs_basic_auth_conflicts_with_auth_method(self.auth_method): + raise ValueError(YAMCS_BASIC_AUTH_CONFLICT_MESSAGE) + if not self.basic_auth_username: + raise ValueError( + "Yamcs HTTP Basic authentication requires additionalFields.basic_auth_username." + ) + if not self.basic_auth_password: + raise ValueError( + "Yamcs HTTP Basic authentication requires additionalFields.basic_auth_password." + ) + def _build_credentials(self): """Build a Yamcs credentials object for the configured auth method.""" if self.auth_method == YAMCS_AUTH_METHOD_NONE: - return None + # Yamcs itself is unauthenticated, so a Basic header only satisfies the proxy. + return self._build_basic_auth_credentials() if self.enable_basic_auth else None try: from yamcs.client import APIKeyCredentials, Credentials @@ -266,6 +289,22 @@ def _build_credentials(self): return Credentials(access_token=auth_key) return Credentials(username=str(self._auth.get("identity") or ""), password=auth_key) + def _build_basic_auth_credentials(self): + """Build credentials that send only the reverse-proxy HTTP Basic header. + + Imported lazily and separately from the other credential types so a deployment + running an older yamcs-client keeps working for every non-proxy auth method. + """ + try: + from yamcs.client import BasicAuthCredentials + except ImportError as exc: + raise ImportError( + "Yamcs HTTP Basic authentication requires yamcs-client 1.8.8 or newer. " + "Upgrade yamcs-client to connect to a Yamcs server behind an authenticating proxy." + ) from exc + + return BasicAuthCredentials(self.basic_auth_username, self.basic_auth_password) + def _connect(self): try: from yamcs.client import YamcsClient @@ -277,7 +316,8 @@ def _connect(self): debug_print( f"[YAMCS_PLUGIN] Opening Yamcs connection server_url={self.server_url} " f"instance={self.instance} processor={self.processor} auth_method={self.auth_method} " - f"tls_verify={self.tls_verify} timeout={self.timeout}" + f"tls_verify={self.tls_verify} timeout={self.timeout} " + f"basic_auth_enabled={self.enable_basic_auth}" ) client = YamcsClient( self.server_url, @@ -290,8 +330,24 @@ def _connect(self): session = getattr(getattr(client, "ctx", None), "session", None) if session is not None: session.request = self._with_timeout(session.request) + self._apply_basic_auth_header(session) return client + def _apply_basic_auth_header(self, session) -> None: + """Attach the proxy Basic header when Yamcs auth does not already own Authorization. + + API key auth travels in ``x-api-key``, leaving the Authorization header free for the + reverse proxy. Unauthenticated Yamcs is handled by ``BasicAuthCredentials`` instead. + """ + if not self.enable_basic_auth or self.auth_method != YAMCS_AUTH_METHOD_API_KEY: + return + session.headers.update({ + "Authorization": build_yamcs_basic_auth_header( + self.basic_auth_username, + self.basic_auth_password, + ) + }) + def _with_timeout(self, request_callable: Callable) -> Callable: configured_timeout = self.timeout diff --git a/application/single_app/static/js/plugin_modal_stepper.js b/application/single_app/static/js/plugin_modal_stepper.js index ccacd6e9f..0743a79b5 100644 --- a/application/single_app/static/js/plugin_modal_stepper.js +++ b/application/single_app/static/js/plugin_modal_stepper.js @@ -13,6 +13,7 @@ const DATABRICKS_ACTION_IDENTITY_AUTH_TYPES = ['api_key', 'bearer_token', 'manag const SNOWFLAKE_ACTION_IDENTITY_AUTH_TYPES = ['api_key', 'bearer_token', 'username_password']; const TABLEAU_ACTION_IDENTITY_AUTH_TYPES = ['api_key', 'username_password']; const YAMCS_ACTION_IDENTITY_AUTH_TYPES = ['api_key', 'bearer_token', 'username_password']; +const YAMCS_BASIC_AUTH_IDENTITY_AUTH_TYPES = ['username_password']; const LOG_ANALYTICS_ACTION_IDENTITY_AUTH_TYPES = ['client_secret', 'managed_identity']; const BLOB_STORAGE_PLUGIN_TYPE = 'blob_storage'; const AZURE_STORAGE_ENDPOINT_SUFFIXES = [ @@ -37,6 +38,19 @@ const YAMCS_AUTH_METHOD_USERNAME_PASSWORD = 'username_password'; const YAMCS_AUTH_METHOD_API_KEY = 'api_key'; const YAMCS_AUTH_METHOD_BEARER_TOKEN = 'bearer_token'; const YAMCS_AUTH_METHOD_NONE = 'none'; +const YAMCS_BASIC_AUTH_COMPATIBLE_AUTH_METHODS = [YAMCS_AUTH_METHOD_NONE, YAMCS_AUTH_METHOD_API_KEY]; +const ACTION_IDENTITY_SELECT_IDS = { + openapi: 'plugin-auth-identity-select', + mcp: 'mcp-identity-select', + databricks: 'databricks-identity-select', + snowflake: 'snowflake-identity-select', + tableau: 'tableau-identity-select', + yamcs: 'yamcs-identity-select', + yamcsBasicAuth: 'yamcs-basic-auth-identity-select', + logAnalytics: 'log-analytics-identity-select', + generic: 'plugin-auth-identity-select-generic', + sql: 'sql-identity-select' +}; const publicWorkspacePlural = window.getPublicWorkspaceLabel ? window.getPublicWorkspaceLabel('plural') : 'Public Workspaces'; const MCP_PLUGIN_TYPE = 'mcp'; const KEY_VAULT_SECRET_REMINDERS_METADATA_FIELD = 'key_vault_secret_reminders'; @@ -544,6 +558,9 @@ export class PluginModalStepper { if (kind === 'yamcs') { return this.actionIdentities.filter(identity => YAMCS_ACTION_IDENTITY_AUTH_TYPES.includes(this.getIdentityAuthType(identity))); } + if (kind === 'yamcsBasicAuth') { + return this.actionIdentities.filter(identity => YAMCS_BASIC_AUTH_IDENTITY_AUTH_TYPES.includes(this.getIdentityAuthType(identity))); + } if (kind === 'logAnalytics') { return this.actionIdentities.filter(identity => LOG_ANALYTICS_ACTION_IDENTITY_AUTH_TYPES.includes(this.getIdentityAuthType(identity))); } @@ -557,18 +574,27 @@ export class PluginModalStepper { this.populateActionIdentitySelector('snowflake', 'snowflake-identity-select', 'snowflake-action-identity-group', 'snowflake-identity-status'); this.populateActionIdentitySelector('tableau', 'tableau-identity-select', 'tableau-action-identity-group', 'tableau-identity-status'); this.populateActionIdentitySelector('yamcs', 'yamcs-identity-select', 'yamcs-action-identity-group', 'yamcs-identity-status'); + this.populateActionIdentitySelector('yamcsBasicAuth', 'yamcs-basic-auth-identity-select', 'yamcs-basic-auth-identity-group', 'yamcs-basic-auth-identity-status'); this.populateActionIdentitySelector('logAnalytics', 'log-analytics-identity-select', 'log-analytics-action-identity-group', 'log-analytics-identity-status'); this.populateActionIdentitySelector('generic', 'plugin-auth-identity-select-generic', 'generic-action-identity-group', 'plugin-auth-identity-status-generic'); this.populateActionIdentitySelector('sql', 'sql-identity-select', 'sql-action-identity-group', 'sql-identity-status'); } + getStoredActionIdentityId(kind) { + if (kind === 'yamcsBasicAuth') { + const additionalFields = this.originalPlugin?.additionalFields || this.originalPlugin?.additional_fields || {}; + return additionalFields.basic_auth_identity_id || ''; + } + return this.originalPlugin?.identity_id || ''; + } + populateActionIdentitySelector(kind, selectId, groupId, statusId) { const select = document.getElementById(selectId); const group = document.getElementById(groupId); const status = document.getElementById(statusId); if (!select || !group) return; - const previousValue = select.value || this.originalPlugin?.identity_id || ''; + const previousValue = select.value || this.getStoredActionIdentityId(kind); const identities = this.getActionIdentitiesForKind(kind); select.replaceChildren(); @@ -614,18 +640,7 @@ export class PluginModalStepper { } getSelectedActionIdentity(kind) { - const selectIds = { - openapi: 'plugin-auth-identity-select', - mcp: 'mcp-identity-select', - databricks: 'databricks-identity-select', - snowflake: 'snowflake-identity-select', - tableau: 'tableau-identity-select', - yamcs: 'yamcs-identity-select', - logAnalytics: 'log-analytics-identity-select', - generic: 'plugin-auth-identity-select-generic', - sql: 'sql-identity-select' - }; - const selectedId = document.getElementById(selectIds[kind])?.value || ''; + const selectedId = document.getElementById(ACTION_IDENTITY_SELECT_IDS[kind])?.value || ''; if (!selectedId) { return null; } @@ -633,18 +648,7 @@ export class PluginModalStepper { } setSelectedActionIdentity(kind, identityId) { - const selectIds = { - openapi: 'plugin-auth-identity-select', - mcp: 'mcp-identity-select', - databricks: 'databricks-identity-select', - snowflake: 'snowflake-identity-select', - tableau: 'tableau-identity-select', - yamcs: 'yamcs-identity-select', - logAnalytics: 'log-analytics-identity-select', - generic: 'plugin-auth-identity-select-generic', - sql: 'sql-identity-select' - }; - const select = document.getElementById(selectIds[kind]); + const select = document.getElementById(ACTION_IDENTITY_SELECT_IDS[kind]); if (!select) return; select.value = identityId || ''; } @@ -658,6 +662,11 @@ export class PluginModalStepper { } handleActionIdentityChange(kind) { + if (kind === 'yamcsBasicAuth') { + this.toggleYamcsBasicAuthFields(); + return; + } + const selectedIdentity = this.getSelectedActionIdentity(kind); if (kind === 'sql') { const authSelect = document.getElementById('sql-auth-type'); @@ -734,6 +743,8 @@ export class PluginModalStepper { document.getElementById('tableau-identity-select').addEventListener('change', () => this.handleActionIdentityChange('tableau')); document.getElementById('yamcs-auth-method').addEventListener('change', () => this.toggleYamcsAuthFields()); document.getElementById('yamcs-identity-select').addEventListener('change', () => this.handleActionIdentityChange('yamcs')); + document.getElementById('yamcs-enable-basic-auth').addEventListener('change', () => this.toggleYamcsBasicAuthFields()); + document.getElementById('yamcs-basic-auth-identity-select').addEventListener('change', () => this.handleActionIdentityChange('yamcsBasicAuth')); const logAnalyticsCloud = document.getElementById('log-analytics-cloud'); if (logAnalyticsCloud) { logAnalyticsCloud.addEventListener('change', () => this.handleLogAnalyticsCloudChange()); @@ -2572,6 +2583,8 @@ export class PluginModalStepper { } }); + this.toggleYamcsBasicAuthFields(); + if (selectedIdentity) { return; } @@ -2586,6 +2599,49 @@ export class PluginModalStepper { } } + getYamcsAuthMethodForConflictCheck() { + const selectedIdentity = this.getSelectedActionIdentity('yamcs'); + if (selectedIdentity) { + return this.getYamcsIdentityAuthMethod(selectedIdentity); + } + return document.getElementById('yamcs-auth-method')?.value || YAMCS_AUTH_METHOD_USERNAME_PASSWORD; + } + + yamcsBasicAuthConflicts() { + return !YAMCS_BASIC_AUTH_COMPATIBLE_AUTH_METHODS.includes(this.getYamcsAuthMethodForConflictCheck()); + } + + isYamcsBasicAuthEnabled() { + return document.getElementById('yamcs-enable-basic-auth')?.checked === true; + } + + toggleYamcsBasicAuthFields() { + const fields = document.getElementById('yamcs-basic-auth-fields'); + const conflictAlert = document.getElementById('yamcs-basic-auth-conflict'); + const usernameInput = document.getElementById('yamcs-basic-auth-username'); + const passwordInput = document.getElementById('yamcs-basic-auth-password'); + const enabled = this.isYamcsBasicAuthEnabled(); + + fields?.classList.toggle('d-none', !enabled); + conflictAlert?.classList.toggle('d-none', !(enabled && this.yamcsBasicAuthConflicts())); + + // A reusable identity supplies both values, so the inline inputs become read-only + // mirrors of the stored credential rather than a second place to edit it. + const selectedIdentity = this.getSelectedActionIdentity('yamcsBasicAuth'); + if (usernameInput) { + usernameInput.disabled = Boolean(selectedIdentity); + if (selectedIdentity) { + usernameInput.value = selectedIdentity.credentials?.username || ''; + } + } + if (passwordInput) { + passwordInput.disabled = Boolean(selectedIdentity); + if (selectedIdentity) { + passwordInput.value = ''; + } + } + } + populateYamcsForm(plugin) { const additionalFields = plugin.additionalFields || plugin.additional_fields || {}; const auth = plugin.auth || {}; @@ -2598,6 +2654,9 @@ export class PluginModalStepper { document.getElementById('yamcs-timeout').value = additionalFields.timeout || 30; document.getElementById('yamcs-tls-verify').checked = additionalFields.tls_verify !== false; document.getElementById('yamcs-enable-archive-sql').checked = additionalFields.enable_archive_sql === true; + document.getElementById('yamcs-enable-basic-auth').checked = additionalFields.enable_basic_auth === true; + document.getElementById('yamcs-basic-auth-username').value = additionalFields.basic_auth_username || ''; + document.getElementById('yamcs-basic-auth-password').value = additionalFields.basic_auth_password || ''; let authMethod = additionalFields.auth_method || YAMCS_AUTH_METHOD_USERNAME_PASSWORD; if (auth.type === 'NoAuth') { @@ -2617,12 +2676,16 @@ export class PluginModalStepper { document.getElementById('yamcs-auth-method').value = authMethod; this.setSelectedActionIdentity('yamcs', plugin.identity_id || ''); + this.setSelectedActionIdentity('yamcsBasicAuth', additionalFields.basic_auth_identity_id || ''); this.handleActionIdentityChange('yamcs'); + this.handleActionIdentityChange('yamcsBasicAuth'); } getYamcsConfiguration() { const serverUrl = this.normalizeYamcsServerUrl(document.getElementById('yamcs-server-url')?.value || ''); const selectedIdentity = this.getSelectedActionIdentity('yamcs'); + const basicAuthIdentity = this.getSelectedActionIdentity('yamcsBasicAuth'); + const enableBasicAuth = this.isYamcsBasicAuthEnabled(); const authMethod = selectedIdentity ? this.getYamcsIdentityAuthMethod(selectedIdentity) : (document.getElementById('yamcs-auth-method')?.value || YAMCS_AUTH_METHOD_USERNAME_PASSWORD); @@ -2634,6 +2697,21 @@ export class PluginModalStepper { tls_verify: document.getElementById('yamcs-tls-verify')?.checked !== false, read_only: true, enable_archive_sql: document.getElementById('yamcs-enable-archive-sql')?.checked === true, + enable_basic_auth: enableBasicAuth, + // Only an identity selection blanks the inline credential. Turning the toggle off + // must keep the stored values, otherwise saving would drop the Key Vault reference + // and leave its secret orphaned. Runtime and validation already ignore these fields + // while the toggle is off. An untouched password field still holds the Key Vault + // placeholder, which the save helper resolves back to the existing reference. + basic_auth_identity_id: basicAuthIdentity + ? (basicAuthIdentity.id || basicAuthIdentity.identity_id || '') + : '', + basic_auth_username: basicAuthIdentity + ? '' + : (document.getElementById('yamcs-basic-auth-username')?.value.trim() || ''), + basic_auth_password: basicAuthIdentity + ? '' + : (document.getElementById('yamcs-basic-auth-password')?.value || ''), max_rows: parseInt(document.getElementById('yamcs-max-rows')?.value, 10) || 500, timeout: parseInt(document.getElementById('yamcs-timeout')?.value, 10) || 30 }; @@ -4249,6 +4327,19 @@ export class PluginModalStepper { this.showError('Yamcs bearer token is required for bearer token authentication.'); return false; } + if (this.isYamcsBasicAuthEnabled()) { + const basicAuthIdentity = this.getSelectedActionIdentity('yamcsBasicAuth'); + const basicAuthUsername = document.getElementById('yamcs-basic-auth-username').value.trim(); + const basicAuthPassword = document.getElementById('yamcs-basic-auth-password').value; + if (this.yamcsBasicAuthConflicts()) { + this.showError('HTTP Basic authentication cannot be combined with username/password or access token authentication. Choose "No Authentication" or "API Key".'); + return false; + } + if (!basicAuthIdentity && (!basicAuthUsername || !basicAuthPassword)) { + this.showError('A proxy username and password are required for HTTP Basic authentication.'); + return false; + } + } if (Number.isNaN(maxRows) || maxRows < 1 || maxRows > 5000) { this.showError('Yamcs max rows must be between 1 and 5000.'); return false; @@ -5188,6 +5279,32 @@ export class PluginModalStepper { return; } + const enableBasicAuth = this.isYamcsBasicAuthEnabled(); + const basicAuthIdentity = this.getSelectedActionIdentity('yamcsBasicAuth'); + const basicAuthUsername = document.getElementById('yamcs-basic-auth-username')?.value?.trim() || ''; + const basicAuthPassword = document.getElementById('yamcs-basic-auth-password')?.value || ''; + + if (enableBasicAuth) { + if (this.yamcsBasicAuthConflicts()) { + resultDiv.classList.remove('d-none'); + alertDiv.className = 'alert alert-warning mb-0 py-2 px-3 small'; + alertDiv.textContent = 'HTTP Basic authentication cannot be combined with username/password or access token authentication. Choose "No Authentication" or "API Key".'; + return; + } + if (basicAuthIdentity) { + resultDiv.classList.remove('d-none'); + alertDiv.className = 'alert alert-warning mb-0 py-2 px-3 small'; + alertDiv.textContent = 'Save the action first to test a connection that uses a reusable identity.'; + return; + } + if ((!basicAuthUsername || !basicAuthPassword) && !existingPluginContext) { + resultDiv.classList.remove('d-none'); + alertDiv.className = 'alert alert-warning mb-0 py-2 px-3 small'; + alertDiv.textContent = 'A proxy username and password are required before testing an HTTP Basic authenticated connection.'; + return; + } + } + const originalText = btn.innerHTML; btn.innerHTML = 'Testing...'; btn.disabled = true; @@ -5208,6 +5325,11 @@ export class PluginModalStepper { if (authMethod !== YAMCS_AUTH_METHOD_NONE) { payload.auth_key = authKey; } + if (enableBasicAuth) { + payload.enable_basic_auth = true; + payload.basic_auth_username = basicAuthUsername; + payload.basic_auth_password = basicAuthPassword; + } if (existingPluginContext) { payload.existing_plugin = existingPluginContext; } @@ -7377,12 +7499,25 @@ export class PluginModalStepper { ? `Reusable Identity (${this.formatYamcsAuthMethod(authMethod)})` : this.formatYamcsAuthMethod(authMethod); document.getElementById('summary-yamcs-tls-verify').textContent = document.getElementById('yamcs-tls-verify')?.checked === false ? 'Disabled' : 'Enabled'; + document.getElementById('summary-yamcs-basic-auth').textContent = this.formatYamcsBasicAuthSummary(); document.getElementById('summary-yamcs-max-rows').textContent = document.getElementById('yamcs-max-rows')?.value.trim() || '500'; document.getElementById('summary-yamcs-timeout').textContent = `${document.getElementById('yamcs-timeout')?.value || '30'} seconds`; document.getElementById('summary-yamcs-archive-sql').textContent = document.getElementById('yamcs-enable-archive-sql')?.checked === true ? 'Enabled (read-only)' : 'Disabled'; yamcsSection.classList.remove('d-none'); } + formatYamcsBasicAuthSummary() { + if (!this.isYamcsBasicAuthEnabled()) { + return 'Disabled'; + } + const basicAuthIdentity = this.getSelectedActionIdentity('yamcsBasicAuth'); + if (basicAuthIdentity) { + return `Enabled (reusable identity: ${basicAuthIdentity.name || 'Workspace identity'})`; + } + const username = document.getElementById('yamcs-basic-auth-username')?.value.trim() || ''; + return username ? `Enabled (${username})` : 'Enabled'; + } + populateMcpSummary() { const mcpSection = document.getElementById('summary-mcp-section'); if (!mcpSection) { diff --git a/application/single_app/static/json/schemas/yamcs_plugin.additional_settings.schema.json b/application/single_app/static/json/schemas/yamcs_plugin.additional_settings.schema.json index 3ebe88e43..2ed05684e 100644 --- a/application/single_app/static/json/schemas/yamcs_plugin.additional_settings.schema.json +++ b/application/single_app/static/json/schemas/yamcs_plugin.additional_settings.schema.json @@ -37,6 +37,23 @@ "default": false, "description": "Allow read-only Yamcs archive SQL statements. Disabled by default." }, + "enable_basic_auth": { + "type": "boolean", + "default": false, + "description": "Send an HTTP Basic Authorization header on every request so a reverse proxy in front of Yamcs can authenticate the caller. Only valid when auth_method is 'none' or 'api_key'." + }, + "basic_auth_username": { + "type": "string", + "description": "Username presented to the reverse proxy for HTTP Basic authentication." + }, + "basic_auth_password": { + "type": "string", + "description": "Password presented to the reverse proxy for HTTP Basic authentication. Stored in Key Vault." + }, + "basic_auth_identity_id": { + "type": "string", + "description": "Optional reusable workspace identity supplying the reverse-proxy username and password, so the credential can be rotated without editing the action." + }, "max_rows": { "type": "integer", "default": 500, diff --git a/application/single_app/templates/_plugin_modal.html b/application/single_app/templates/_plugin_modal.html index 177797fda..e434bbd3a 100644 --- a/application/single_app/templates/_plugin_modal.html +++ b/application/single_app/templates/_plugin_modal.html @@ -600,6 +600,45 @@
API Information
+
+ +
+ + +
+ Enable this when a reverse proxy such as Apache challenges every request before it reaches Yamcs. + Leave it off for a Yamcs server you reach directly, such as a local simulator. +
+
+
+ +
+ + +
+ Select a saved username and password identity so the credential can be rotated without editing this action. +
+
+
+
+ + +
+
+ + +
+
+
+
+
+
+
+ + - +
+
diff --git a/docs/explanation/features/YAMCS_ACTION.md b/docs/explanation/features/YAMCS_ACTION.md index 279c365d7..8c4382017 100644 --- a/docs/explanation/features/YAMCS_ACTION.md +++ b/docs/explanation/features/YAMCS_ACTION.md @@ -87,6 +87,10 @@ Every retrieval is bounded before it reaches an agent: - `tls_verify`: verify the server TLS certificate; defaults to `true`. - `read_only`: always `true`; stored for parity with other connector actions. - `enable_archive_sql`: allow guarded read-only archive SQL; defaults to `false`. +- `enable_basic_auth`: send an HTTP Basic `Authorization` header so a reverse proxy in front of Yamcs can authenticate the caller; defaults to `false`. +- `basic_auth_username`: username presented to the reverse proxy. +- `basic_auth_password`: password presented to the reverse proxy; stored in Key Vault. +- `basic_auth_identity_id`: optional reusable identity supplying the proxy username and password. - `max_rows`: per-call row limit, bounded from 1 to 5000; defaults to 500. - `timeout`: HTTP request timeout in seconds, bounded from 1 to 300; defaults to 30. - `byte_limit`: approximate serialized result size limit, bounded from 1000 to 2000000. @@ -103,12 +107,62 @@ Every retrieval is bounded before it reaches an agent: Reusable identities are accepted when their auth type is `api_key`, `bearer_token`, or `username_password`. +### Reverse proxy HTTP Basic authentication + +Added in version 0.261.012. + +A ground segment commonly publishes Yamcs through a reverse proxy, such as Apache, that +enforces HTTP Basic authentication against a directory before a request reaches Yamcs. +Yamcs behind such a proxy often runs with no authentication of its own. `enable_basic_auth` +addresses that topology as a layer independent of the Yamcs authentication method. + +| Yamcs `auth_method` | Allowed with `enable_basic_auth` | Runtime behavior | +| --- | --- | --- | +| `none` | Yes | `BasicAuthCredentials(username, password)` is passed to `YamcsClient`, which sends `Authorization: Basic …` on every request. | +| `api_key` | Yes | `APIKeyCredentials` continues to send `x-api-key`, and the plugin sets the `Authorization` header on the client session. | +| `username_password` | No | Yamcs exchanges the credential for a bearer token carried in `Authorization`, and the `/auth/token` request would itself be refused by the proxy. | +| `bearer_token` | No | The bearer token also requires `Authorization`. | + +The blocked combinations are a property of HTTP rather than a limitation of the +implementation: only one `Authorization` header can be sent. The rule is enforced in +`YamcsPlugin._validate_basic_auth_configuration`, in `PluginHealthChecker` at save time, in +the test-connection route, and in the action modal. + +`BasicAuthCredentials` is imported separately from the other credential classes so a +deployment running a yamcs-client older than 1.8.8 keeps working for every non-proxy +authentication method and receives an actionable upgrade message only when proxy +authentication is actually requested. + +`basic_auth_password` is listed in `YAMCS_SENSITIVE_ADDITIONAL_FIELDS`, so it inherits the +existing Key Vault storage, redaction, retrieval, and deletion behavior used by other action +secrets. + +### Proxy credential identity mapping + +The proxy credential has its own reusable identity reference, separate from the Yamcs +credential reference, so an action can use both at once. Because HTTP Basic authentication +is always a username and password pair, only `username_password` identities are offered. + +| Reference | Manifest location | Accepted identity auth types | +| --- | --- | --- | +| Yamcs credential | `identity_id` | `api_key`, `bearer_token`, `username_password` | +| Proxy credential | `additionalFields.basic_auth_identity_id` | `username_password` | + +Both references are resolved by `hydrate_action_identity_reference`, so every personal, +group, and global action path picks up the proxy reference without further changes. UI +hydration returns the identity's username but never its password; runtime hydration resolves +both. An identity resolves within the workspace scope that owns the action, so a personal +action paired with a personal identity uses that user's own credential. A shared group +action resolves one group-scoped credential rather than a distinct credential per member. + ## Usage Instructions Create a new action from a personal, group, or admin action surface and choose **Yamcs**. Enter the Yamcs server URL and the instance name, and optionally change the processor from the `realtime` default. Choose an authentication method and supply the matching credential, or select an action-capable reusable identity from the Yamcs identity selector. Choose **No Authentication** only for unsecured Yamcs instances such as a local simulator. +If the server is published through a proxy that challenges callers, enable **Reverse Proxy Authentication** and supply the proxy username and password. Where that password is reissued periodically, select a reusable identity instead so the credential is rotated once under **Workspace → Identities** rather than by editing every action. Leave the option off when reaching Yamcs directly. + Use **Test Yamcs Connection** to confirm the server is reachable, the credentials are accepted, and the configured instance exists. The test reports the Yamcs version and the number of available instances. When editing a saved action, the stored credential is resolved from Key Vault automatically, so the secret does not need to be re-entered to run a test. If your agents need ad hoc archive queries, enable **read-only archive SQL**. Leave it off unless it is needed. @@ -118,6 +172,7 @@ After saving the action, assign it to agents that need Yamcs telemetry or archiv ## Testing and Validation - Functional coverage: `functional_tests/test_yamcs_action_plugin.py` (14 tests) +- Reverse proxy coverage: `functional_tests/test_yamcs_basic_auth.py` (12 tests) - Route policy coverage: `functional_tests/route_tests/` — the new endpoint is covered by the existing authenticated-route prefix rules - JavaScript syntax checks: `plugin_modal_stepper.js` and `workspace/view-utils.js` - Python compile checks cover the Yamcs helper, plugin, factory, loaders, health checker, routes, identity, Key Vault, and governance updates @@ -129,4 +184,6 @@ After saving the action, assign it to agents that need Yamcs telemetry or archiv - Yamcs permissions are enforced by Yamcs for the configured credentials. - Streaming and subscription APIs (parameter, packet, event, and alarm subscriptions) are not exposed, because agent tool calls are request/response. - Testing a connection that uses a reusable identity requires saving the action first. +- Reverse proxy HTTP Basic authentication cannot be combined with Yamcs username/password or access token authentication, because only one `Authorization` header can be sent. +- A shared group action resolves a single group-scoped proxy credential. Per-user credentials on one shared action are not supported; use a personal action with a personal identity where each operator must present their own credential. - Live connectivity is validated only when credentials and a reachable Yamcs server are configured in the running app. diff --git a/docs/explanation/release-notes/index.md b/docs/explanation/release-notes/index.md index 510f205a1..45db4c558 100644 --- a/docs/explanation/release-notes/index.md +++ b/docs/explanation/release-notes/index.md @@ -20,6 +20,12 @@ This page includes the latest release notes inline. Older release sections are s | Version | Page | | --- | --- | +| v0.261.010 | [Release notes index]({{ '/explanation/release_notes/' | relative_url }}) | +| v0.261.009 | [Release notes index]({{ '/explanation/release_notes/' | relative_url }}) | +| v0.261.007 | [Release notes index]({{ '/explanation/release_notes/' | relative_url }}) | +| v0.261.006 | [Release notes index]({{ '/explanation/release_notes/' | relative_url }}) | +| v0.261.005 | [Release notes index]({{ '/explanation/release_notes/' | relative_url }}) | +| v0.261.004 | [Release notes index]({{ '/explanation/release_notes/' | relative_url }}) | | v0.261.003 | [Release notes index]({{ '/explanation/release_notes/' | relative_url }}) | | v0.261.002 | [Release notes index]({{ '/explanation/release_notes/' | relative_url }}) | | v0.261.001 | [Release notes 0.261 series]({{ '/explanation/release-notes/v0.261/' | relative_url }}) | @@ -71,6 +77,73 @@ This page includes the latest release notes inline. Older release sections are s ## Latest release notes +### **(v0.261.010)** + +#### New Features + +* **Yamcs Actions Can Reach Servers Behind An Authenticating Proxy** + * Ground segments commonly publish Yamcs through a reverse proxy, such as Apache, that challenges every request with HTTP Basic authentication against a directory before the request reaches Yamcs. Yamcs behind that proxy often has no authentication of its own. Until now a Yamcs action could not answer that challenge, so such a server was unreachable even when the Yamcs settings were correct. + * A new **Reverse Proxy Authentication** option on the Yamcs action sends an HTTP Basic `Authorization` header on every request. It is off by default, so a Yamcs server reached directly, such as a local simulator, is unaffected. + * The proxy credential can be typed on the action, with the password stored in Key Vault, or supplied by a reusable **username and password identity**. Where a directory issues temporary passwords, the identity is rotated once under **Workspace → Identities** and every action that references it picks up the new password without being edited. + * The proxy credential has its own identity reference, separate from the Yamcs credential, so one action can use both. + * Proxy authentication combines with the **No Authentication** and **API Key** Yamcs methods. It cannot combine with **Username and Password** or **Access Token**, because only one `Authorization` header can be sent and the Yamcs token request would itself be refused by the proxy. The conflict is reported when saving the action and when running **Test Yamcs Connection** rather than failing later at run time. + * **Test Yamcs Connection** exercises the proxy credential and distinguishes a proxy rejection from a Yamcs rejection. + * (Ref: `functions_yamcs_operations.py`, `yamcs_plugin.py`, `functions_workspace_identities.py`, `plugin_health_checker.py`, `route_backend_plugins.py`, `_plugin_modal.html`, `plugin_modal_stepper.js`, `test_yamcs_basic_auth.py`, [Yamcs Action](features/YAMCS_ACTION.md), [#1435](https://github.com/microsoft/simplechat/issues/1435)) + +### **(v0.261.009)** + +#### Bug Fixes + +* **Shared Workspace File Approvals Are Visible To Approvers Again** + * Fixed document access index candidate selection for workspace scope projections so pending-approval records are considered alongside already granted records. + * Shared files staged for approval are intentionally not granted yet, so filtering only on `access_granted = true` could hide those files from approval experiences even though they were eligible for review. + * The projection query now includes `approval_status = not_approved` rows while still requiring current-version projection records. + * (Ref: `functions_document_access_index.py`, [Workspace Shared File Approval Visibility Fix](fixes/WORKSPACE_SHARED_FILE_APPROVAL_VISIBILITY_FIX.md)) + +* **Distroless Runtime Copy No Longer Fails On `/usr/lib64` Overlay Conflicts** + * Fixed Docker BuildKit failures where `COPY --from=builder /odbc-runtime/ /` or `COPY --from=builder /playwright-runtime/ /` could abort with `cannot copy to non-directory ... /usr/lib64` when the distroless base exposes `/usr/lib64` as a non-directory entry. + * Updated runtime staging to copy native shared libraries into `/odbc-runtime/usr/lib` and `/playwright-runtime/usr/lib` while continuing to source candidates from both `/usr/lib64` and `/usr/lib` in the builder stage. + * This preserves SQL ODBC and Playwright Chromium runtime packaging while avoiding path-type collisions against evolving base-image filesystem layouts. + * (Ref: `Dockerfile`, `test_sql_container_odbc_runtime.py`, `test_deep_research_chromium_build_opt_out.py`, [Distroless Runtime Overlay Path Fix](fixes/DISTROLESS_RUNTIME_OVERLAY_PATH_FIX.md)) + +### **(v0.261.007)** + +#### Bug Fixes + +* **Markdown Retry Helper Return Contract Clarified** + * Added an explicit defensive exception at the end of the Markdown `OrderedDict` retry helper so static analysis no longer sees a possible implicit `None` return. + * Runtime behavior is unchanged for normal success and retry-exhaustion paths. + * (Ref: `functions_documents.py`, [Markdown Retry Return Contract Fix](fixes/MARKDOWN_RETRY_RETURN_CONTRACT_FIX.md)) + +### **(v0.261.006)** + +#### Bug Fixes + +* **Markdown Uploads Retry Transient OrderedDict Parser Failures** + * Markdown document processing now retries the known transient `OrderedDict mutated during iteration` parser failure before marking a document failed. + * The retry is limited to this specific Markdown failure signature, so unrelated parsing, validation, or service errors still fail normally with their original error. + * (Ref: Markdown upload processing, `functions_documents.py`, `test_markdown_processing_batches_search_writes.py`, [Search Write Gate Upload Contention Fix](fixes/SEARCH_WRITE_GATE_UPLOAD_CONTENTION_FIX.md)) + +### **(v0.261.005)** + +#### User Interface Enhancements + +* **Workspace Upload Progress Now Separates Request Status From Document Processing Status** + * The temporary upload summary no longer labels unconfirmed browser upload requests as final document failures. This avoids misleading summaries such as `Uploaded 77/204, Failed: 127` when the document list later shows that most documents were queued and processed successfully. + * Personal, group, and public workspace uploads now use `Queued` for confirmed upload requests and direct users to the refreshed document list for final processing status. + * (Ref: workspace upload progress summary, `workspace-documents.js`, `public_workspace.js`, `group_workspaces.html`, [Workspace Upload Status Counter Fix](fixes/WORKSPACE_UPLOAD_STATUS_COUNTER_FIX.md)) + +### **(v0.261.004)** + +#### Bug Fixes + +* **Large Workspace Uploads No Longer Fail On Search Write Gate Contention** + * Fixed partial failures when uploading many small Markdown, JSON, or YAML files to personal, group, or public workspaces at once. Document processing could fail with a message that the Data Management Search write gate changed too often to reserve a write slot. + * The shared write gate now waits within the existing request timeout budget, briefly backs off after transient Cosmos ETag conflicts, and serializes Search writes inside each worker process. This prevents local upload threads from stampeding the same gate document while preserving the migration freeze protection for Azure AI Search writes. + * Markdown processing now batches its chunk embeddings and Search upload instead of reserving the gate once per chunk, which reduces contention and avoids the intermittent `OrderedDict mutated during iteration` failures seen during concurrent Markdown ingestion. + * Added regression coverage for repeated transient gate conflicts, local worker serialization, and Markdown use of the batch chunk writer. + * (Ref: `functions_data_management_search_write_fence.py`, `functions_documents.py`, `test_data_management_search_write_fence.py`, `test_markdown_processing_batches_search_writes.py`, [Search Write Gate Upload Contention Fix](fixes/SEARCH_WRITE_GATE_UPLOAD_CONTENTION_FIX.md)) + ### **(v0.261.003)** #### Bug Fixes @@ -137,3 +210,17 @@ This page includes the latest release notes inline. Older release sections are s * Page and slide counts are left uncapped here, since how much text a page holds is not known until extraction runs. They are bounded when the chunk is indexed instead. * No shipping default changed. Only custom overrides that could never have been indexed are affected. * (Ref: `get_chunk_size_cap`, `get_chunk_size_config`, Document Extraction settings, `admin_settings.js`) + +* **Logout No Longer Redirects To A Missing Easy Auth Endpoint** + * Logout could redirect to `/.auth/logout?post_logout_redirect_uri=%2Flogin` and return a 404 on Azure App Service deployments that were not actually serving App Service Easy Auth. This affected production deployments as well as development ones. + * The root cause was Easy Auth detection treating the manually configured `WEBSITE_AUTH_AAD_ALLOWED_TENANTS` application setting as proof that Easy Auth was intercepting requests. SimpleChat's own advanced environment variable guidance instructs operators to set that value by hand, so it was never a reliable signal. + * Detection now relies only on the `X-MS-CLIENT-PRINCIPAL` request headers that App Service Easy Auth injects on requests it actually intercepts, so deployments genuinely behind Easy Auth still clear the upstream platform session, and everyone else gets a clean local logout. + * Idle-timeout logout uses the same local logout path, so automatic session expiration follows the corrected behavior as well. + * (Ref: `route_frontend_authentication.py`, `_use_app_service_easy_auth_logout`, `test_app_service_easy_auth_logout.py`, [Easy Auth Logout Detection Fix](fixes/EASY_AUTH_LOGOUT_DETECTION_FIX.md)) + +#### New Features + +* **Opt-Out For App Service Easy Auth Logout** + * Added the `DISABLE_APP_SERVICE_EASY_AUTH_LOGOUT` environment variable for deployments where Easy Auth is genuinely active but the platform `/.auth/logout` endpoint is not reachable on the public host, such as when a custom domain or gateway does not route `/.auth/*` to the App Service origin. + * Setting it to `true` keeps logout on the local path instead of redirecting to the platform endpoint. Logout routing decisions are now also traced through debug logging, so `FLASK_DEBUG=1` shows which path was taken and why. + * (Ref: `DISABLE_APP_SERVICE_EASY_AUTH_LOGOUT`, `config.py`, `example.env`, [Running SimpleChat Locally](running_simplechat_locally.md)) diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md index 608dfc684..9aa1fc5e4 100644 --- a/docs/explanation/release_notes.md +++ b/docs/explanation/release_notes.md @@ -2,6 +2,19 @@ For feature-focused and fix-focused drill-downs by version, see [Features by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/features) and [Fixes by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/fixes). +### **(v0.261.012)** + +#### New Features + +* **Yamcs Actions Can Reach Servers Behind An Authenticating Proxy** + * Ground segments commonly publish Yamcs through a reverse proxy, such as Apache, that challenges every request with HTTP Basic authentication against a directory before the request reaches Yamcs. Yamcs behind that proxy often has no authentication of its own. Until now a Yamcs action could not answer that challenge, so such a server was unreachable even when the Yamcs settings were correct. + * A new **Reverse Proxy Authentication** option on the Yamcs action sends an HTTP Basic `Authorization` header on every request. It is off by default, so a Yamcs server reached directly, such as a local simulator, is unaffected. + * The proxy credential can be typed on the action, with the password stored in Key Vault, or supplied by a reusable **username and password identity**. Where a directory issues temporary passwords, the identity is rotated once under **Workspace → Identities** and every action that references it picks up the new password without being edited. + * The proxy credential has its own identity reference, separate from the Yamcs credential, so one action can use both. + * Proxy authentication combines with the **No Authentication** and **API Key** Yamcs methods. It cannot combine with **Username and Password** or **Access Token**, because only one `Authorization` header can be sent and the Yamcs token request would itself be refused by the proxy. The conflict is reported when saving the action and when running **Test Yamcs Connection** rather than failing later at run time. + * **Test Yamcs Connection** exercises the proxy credential and distinguishes a proxy rejection from a Yamcs rejection. + * (Ref: `functions_yamcs_operations.py`, `yamcs_plugin.py`, `functions_workspace_identities.py`, `plugin_health_checker.py`, `route_backend_plugins.py`, `_plugin_modal.html`, `plugin_modal_stepper.js`, `test_yamcs_basic_auth.py`, [Yamcs Action](features/YAMCS_ACTION.md), [#1435](https://github.com/microsoft/simplechat/issues/1435)) + ### **(v0.261.011)** #### Bug Fixes diff --git a/docs/reference/actions/yamcs.md b/docs/reference/actions/yamcs.md index f87d02058..b243423b3 100644 --- a/docs/reference/actions/yamcs.md +++ b/docs/reference/actions/yamcs.md @@ -21,6 +21,7 @@ Use it for mission-control visibility. Do not use it for commanding; the plugin ## Before you start - Yamcs server URL, instance, processor, auth method, and retrieval limits. +- If the server sits behind a reverse proxy that challenges callers, the username and password that proxy expects. - Users also need access to the action through workspace or governance policy where applicable. ## Configuration overview @@ -29,6 +30,50 @@ Set Server URL, Instance, Processor, Authentication Method, credentials, Max Row Shared wizard steps: [Common action setup steps](../#common-action-setup-steps). +## Reaching a Yamcs server behind an authenticating proxy + +Ground segments often publish Yamcs through a reverse proxy, such as Apache, that +challenges every request with HTTP Basic authentication against a directory before the +request reaches Yamcs at all. Yamcs behind that proxy frequently has no authentication of +its own. Without a way to answer the proxy challenge, SimpleChat cannot reach such a server +even though the Yamcs configuration is correct. + +**Reverse Proxy Authentication** covers that case. Turning it on makes the action send an +HTTP Basic `Authorization` header on every request, which satisfies the proxy and is then +consumed before Yamcs sees it. Leave it off when you reach Yamcs directly, such as a local +simulator, so no unnecessary credential is sent. + +### Which authentication methods it can be combined with + +HTTP Basic authentication uses the `Authorization` header, and so do two of the Yamcs +authentication methods. That makes some combinations impossible rather than merely +unsupported: + +| Yamcs Authentication Method | Works with proxy Basic auth | Why | +|---|---|---| +| No Authentication | Yes | Yamcs sends nothing, so the header is free for the proxy. This is the usual case for a proxied dev or mission server. | +| API Key | Yes | The Yamcs API key travels in the `x-api-key` header, leaving `Authorization` for the proxy. | +| Username and Password | No | Yamcs exchanges the credentials for a bearer token carried in `Authorization`, and the token request itself would be refused by the proxy. | +| Access Token | No | The bearer token also needs `Authorization`. | + +Choosing a blocked combination is reported when you save the action and when you use +**Test Yamcs Connection**, so the conflict surfaces before an agent depends on it. + +### Supplying the proxy credential + +The proxy username and password can be entered directly on the action. The password is +stored in Key Vault, never in the action document, and is never returned to the browser. + +Where the credential expires and is reissued, which is common for directory-issued +temporary passwords, select a **Reusable Identity** instead. The action then stores only a +reference, and the credential is maintained once under **Workspace → Identities**. Rotating +it there updates every action that points at it, so a new password does not require editing +the action. Only username and password identities appear in this list, because HTTP Basic +authentication is always a username and password pair. + +An identity resolves within the workspace that owns the action. A personal action paired +with a personal identity therefore uses that person's own credential. + ## Related - [Actions reference index]({{ '/reference/actions/' | relative_url }}) diff --git a/functional_tests/test_yamcs_basic_auth.py b/functional_tests/test_yamcs_basic_auth.py new file mode 100644 index 000000000..349fa4f90 --- /dev/null +++ b/functional_tests/test_yamcs_basic_auth.py @@ -0,0 +1,552 @@ +# test_yamcs_basic_auth.py +#!/usr/bin/env python3 +""" +Functional test for Yamcs reverse-proxy HTTP Basic authentication. +Version: 0.261.012 +Implemented in: 0.261.012 + +This test ensures a Yamcs action can authenticate against a reverse proxy (such as the +Apache front end on a ground-segment dev server) that enforces HTTP Basic authentication +before the request reaches Yamcs. It covers additionalFields normalization, Authorization +header construction, the auth-method compatibility rules, credential selection at connect +time, manifest health validation, Key Vault secret classification, and the reusable +identity reference used to supply the proxy credential without editing the action. +""" + +import base64 +import sys +import traceback +import types +from pathlib import Path + +from test_support.versioning import assert_app_version_at_least + + +APP_DIR = Path(__file__).resolve().parents[1] / "application" / "single_app" +sys.path.insert(0, str(APP_DIR)) + +simplechat_operations_stub = types.ModuleType("functions_simplechat_operations") +simplechat_operations_stub.SIMPLECHAT_DEFAULT_ENDPOINT = "simplechat://internal" +sys.modules.setdefault("functions_simplechat_operations", simplechat_operations_stub) + + +def plugin_function_logger(_plugin_name): + def decorator(function): + return function + + return decorator + + +plugin_invocation_logger_stub = types.ModuleType("semantic_kernel_plugins.plugin_invocation_logger") +plugin_invocation_logger_stub.plugin_function_logger = plugin_function_logger +sys.modules.setdefault("semantic_kernel_plugins.plugin_invocation_logger", plugin_invocation_logger_stub) + + +class FakeConfigCosmosContainer: + """Minimal Cosmos container stand-in for importing app config in tests.""" + + def __init__(self): + self.items = {} + + def read_item(self, item, partition_key=None): + if item in self.items: + return self.items[item] + if item == "app_settings": + return {"id": "app_settings", "settings": {}} + raise KeyError(item) + + def upsert_item(self, item): + self.items[item["id"]] = item + return item + + def query_items(self, *args, **kwargs): + return [] + + +class FakeConfigCosmosDatabase: + """Minimal Cosmos database stand-in for importing config.py without live I/O.""" + + def __init__(self): + self.containers = {} + + def create_container_if_not_exists(self, id, **kwargs): + self.containers.setdefault(id, FakeConfigCosmosContainer()) + return self.containers[id] + + +class FakeConfigCosmosClient: + """Minimal Cosmos client stand-in for config.py import-time container setup.""" + + def __init__(self, *args, **kwargs): + self.database = FakeConfigCosmosDatabase() + + def create_database_if_not_exists(self, *args, **kwargs): + return self.database + + +import azure.cosmos as azure_cosmos # noqa: E402 + +original_cosmos_client = azure_cosmos.CosmosClient +azure_cosmos.CosmosClient = FakeConfigCosmosClient +try: + from functions_yamcs_operations import ( # noqa: E402 + YAMCS_AUTH_METHOD_API_KEY, + YAMCS_AUTH_METHOD_BEARER_TOKEN, + YAMCS_AUTH_METHOD_NONE, + YAMCS_AUTH_METHOD_USERNAME_PASSWORD, + YAMCS_BASIC_AUTH_COMPATIBLE_AUTH_METHODS, + YAMCS_PLUGIN_TYPE, + YAMCS_SENSITIVE_ADDITIONAL_FIELDS, + build_yamcs_basic_auth_header, + normalize_yamcs_additional_fields, + yamcs_basic_auth_conflicts_with_auth_method, + ) + from functions_keyvault import _is_sensitive_plugin_additional_field # noqa: E402 + from functions_workspace_identities import ( # noqa: E402 + ACTION_PROXY_IDENTITY_AUTH_TYPES, + ACTION_PROXY_IDENTITY_FIELD, + ACTION_PROXY_IDENTITY_TYPES, + get_action_proxy_identity_reference_id, + ) + from semantic_kernel_plugins.plugin_health_checker import PluginHealthChecker # noqa: E402 + from semantic_kernel_plugins.yamcs_plugin_factory import YamcsPluginFactory # noqa: E402 +finally: + azure_cosmos.CosmosClient = original_cosmos_client + + +class FakeSession: + """requests.Session stand-in that records the headers the plugin applies.""" + + def __init__(self): + self.headers = {} + + def request(self, *args, **kwargs): + return None + + +class FakeContext: + def __init__(self): + self.session = FakeSession() + + +class FakeYamcsClient: + def __init__(self, address, credentials=None, tls_verify=True, user_agent=None, **kwargs): + self.address = address + self.credentials = credentials + self.tls_verify = tls_verify + self.user_agent = user_agent + self.ctx = FakeContext() + self.closed = False + + def close(self): + self.closed = True + + +class FakeCredentials: + def __init__(self, username=None, password=None, access_token=None, **kwargs): + self.username = username + self.password = password + self.access_token = access_token + + +class FakeAPIKeyCredentials: + """Mirrors yamcs.client.APIKeyCredentials, which stores the key on `password`.""" + + def __init__(self, key): + self.password = key + + +class FakeBasicAuthCredentials: + """Mirrors yamcs.client.BasicAuthCredentials, which sends an Authorization header.""" + + def __init__(self, username, password): + self.username = username + self.password = password + + +def install_fake_yamcs_client(include_basic_auth=True): + """Install a fake yamcs.client module so the plugin's lazy imports resolve in tests.""" + yamcs_package = sys.modules.get("yamcs") or types.ModuleType("yamcs") + client_module = types.ModuleType("yamcs.client") + client_module.YamcsClient = FakeYamcsClient + client_module.Credentials = FakeCredentials + client_module.APIKeyCredentials = FakeAPIKeyCredentials + if include_basic_auth: + client_module.BasicAuthCredentials = FakeBasicAuthCredentials + yamcs_package.client = client_module + sys.modules["yamcs"] = yamcs_package + sys.modules["yamcs.client"] = client_module + + +def build_basic_auth_manifest(**additional_field_overrides): + """Build a Yamcs manifest for an unauthenticated server behind an authenticating proxy.""" + additional_fields = { + "server_url": "https://dev.example.gov:8090", + "instance": "simulator", + "processor": "realtime", + "auth_method": YAMCS_AUTH_METHOD_NONE, + "enable_basic_auth": True, + "basic_auth_username": "jdoe", + "basic_auth_password": "temp-password", + "max_rows": 100, + "timeout": 30, + } + additional_fields.update(additional_field_overrides) + return { + "name": "yamcs_dev_server", + "type": YAMCS_PLUGIN_TYPE, + "endpoint": "https://dev.example.gov:8090", + "auth": {"type": "NoAuth"}, + "additionalFields": additional_fields, + "metadata": {"description": "Yamcs dev server behind an Apache proxy"}, + } + + +def test_basic_auth_normalization_defaults(): + """Basic auth fields normalize with safe defaults and preserve stored values.""" + print("Testing Yamcs basic auth normalization...") + + defaults = normalize_yamcs_additional_fields( + {"server_url": "yamcs.example.com:8090", "instance": "simulator"}, + auth_type="NoAuth", + ) + assert defaults["enable_basic_auth"] is False + assert defaults["basic_auth_username"] == "" + assert defaults["basic_auth_password"] == "" + assert defaults[ACTION_PROXY_IDENTITY_FIELD] == "" + + enabled = normalize_yamcs_additional_fields( + { + "server_url": "dev.example.gov:8090", + "instance": "simulator", + "enable_basic_auth": "true", + "basic_auth_username": " jdoe ", + "basic_auth_password": "temp-password", + "basic_auth_identity_id": " identity-123 ", + }, + auth_type="NoAuth", + ) + assert enabled["enable_basic_auth"] is True + assert enabled["basic_auth_username"] == "jdoe" + assert enabled["basic_auth_password"] == "temp-password" + assert enabled[ACTION_PROXY_IDENTITY_FIELD] == "identity-123" + + # Turning the toggle off must not discard the stored credential, otherwise the Key + # Vault secret backing it would be orphaned on the next save. + disabled = normalize_yamcs_additional_fields( + { + "server_url": "dev.example.gov:8090", + "instance": "simulator", + "enable_basic_auth": False, + "basic_auth_username": "jdoe", + "basic_auth_password": "temp-password", + }, + auth_type="NoAuth", + ) + assert disabled["enable_basic_auth"] is False + assert disabled["basic_auth_username"] == "jdoe" + assert disabled["basic_auth_password"] == "temp-password" + + print("Yamcs basic auth normalization passed.") + return True + + +def test_basic_auth_header_encoding(): + """The Authorization header is a correctly encoded HTTP Basic credential.""" + print("Testing Yamcs basic auth header encoding...") + + header = build_yamcs_basic_auth_header("jdoe", "p@ss:word") + assert header.startswith("Basic ") + decoded = base64.b64decode(header.split(" ", 1)[1]).decode("utf-8") + assert decoded == "jdoe:p@ss:word" + + # Non-ASCII passwords must survive the round trip rather than raising. + unicode_header = build_yamcs_basic_auth_header("jdoe", "pässwörd") + unicode_decoded = base64.b64decode(unicode_header.split(" ", 1)[1]).decode("utf-8") + assert unicode_decoded == "jdoe:pässwörd" + + print("Yamcs basic auth header encoding passed.") + return True + + +def test_basic_auth_compatibility_rules(): + """Basic auth is allowed only where it does not fight for the Authorization header.""" + print("Testing Yamcs basic auth compatibility rules...") + + assert YAMCS_BASIC_AUTH_COMPATIBLE_AUTH_METHODS == { + YAMCS_AUTH_METHOD_NONE, + YAMCS_AUTH_METHOD_API_KEY, + } + assert yamcs_basic_auth_conflicts_with_auth_method(YAMCS_AUTH_METHOD_NONE) is False + assert yamcs_basic_auth_conflicts_with_auth_method(YAMCS_AUTH_METHOD_API_KEY) is False + assert yamcs_basic_auth_conflicts_with_auth_method(YAMCS_AUTH_METHOD_USERNAME_PASSWORD) is True + assert yamcs_basic_auth_conflicts_with_auth_method(YAMCS_AUTH_METHOD_BEARER_TOKEN) is True + + print("Yamcs basic auth compatibility rules passed.") + return True + + +def test_plugin_validation_rejects_incomplete_and_conflicting_configurations(): + """The plugin refuses to build when basic auth is enabled but unusable.""" + print("Testing Yamcs basic auth plugin validation...") + + install_fake_yamcs_client() + + plugin = YamcsPluginFactory.create_from_config(build_basic_auth_manifest()) + assert plugin.enable_basic_auth is True + assert plugin.basic_auth_username == "jdoe" + + for missing_field in ("basic_auth_username", "basic_auth_password"): + try: + YamcsPluginFactory.create_from_config( + build_basic_auth_manifest(**{missing_field: ""}) + ) + except ValueError as exc: + assert missing_field in str(exc) + else: + raise AssertionError(f"Missing {missing_field} should raise a configuration error") + + conflicting_manifest = build_basic_auth_manifest(auth_method=YAMCS_AUTH_METHOD_BEARER_TOKEN) + conflicting_manifest["auth"] = {"type": "key", "key": "token-value"} + try: + YamcsPluginFactory.create_from_config(conflicting_manifest) + except ValueError as exc: + assert "Authorization header" in str(exc) + else: + raise AssertionError("Bearer token plus basic auth should raise a configuration error") + + print("Yamcs basic auth plugin validation passed.") + return True + + +def test_unauthenticated_yamcs_uses_basic_auth_credentials(): + """An unauthenticated Yamcs server behind a proxy uses BasicAuthCredentials.""" + print("Testing Yamcs basic auth credential selection...") + + install_fake_yamcs_client() + + plugin = YamcsPluginFactory.create_from_config(build_basic_auth_manifest()) + credentials = plugin._build_credentials() + assert isinstance(credentials, FakeBasicAuthCredentials) + assert credentials.username == "jdoe" + assert credentials.password == "temp-password" + + client = plugin._connect() + assert isinstance(client.credentials, FakeBasicAuthCredentials) + # BasicAuthCredentials already sends Authorization, so the plugin must not double-apply it. + assert "Authorization" not in client.ctx.session.headers + + # With the toggle off the plugin must stay fully anonymous. + anonymous_plugin = YamcsPluginFactory.create_from_config( + build_basic_auth_manifest(enable_basic_auth=False) + ) + assert anonymous_plugin._build_credentials() is None + + print("Yamcs basic auth credential selection passed.") + return True + + +def test_api_key_auth_sets_basic_header_on_session(): + """API key auth keeps x-api-key while the proxy credential rides on Authorization.""" + print("Testing Yamcs basic auth alongside API key auth...") + + install_fake_yamcs_client() + + manifest = build_basic_auth_manifest(auth_method=YAMCS_AUTH_METHOD_API_KEY) + manifest["auth"] = {"type": "key", "key": "api-key-value"} + plugin = YamcsPluginFactory.create_from_config(manifest) + + credentials = plugin._build_credentials() + assert isinstance(credentials, FakeAPIKeyCredentials) + assert credentials.password == "api-key-value" + + client = plugin._connect() + expected_header = build_yamcs_basic_auth_header("jdoe", "temp-password") + assert client.ctx.session.headers["Authorization"] == expected_header + + print("Yamcs basic auth alongside API key auth passed.") + return True + + +def test_missing_basic_auth_client_support_is_reported(): + """An older yamcs-client without BasicAuthCredentials produces an actionable error.""" + print("Testing Yamcs basic auth dependency handling...") + + install_fake_yamcs_client(include_basic_auth=False) + plugin = YamcsPluginFactory.create_from_config(build_basic_auth_manifest()) + + try: + plugin._build_credentials() + except ImportError as exc: + assert "yamcs-client" in str(exc) + else: + raise AssertionError("A client without BasicAuthCredentials should raise ImportError") + + # Every other auth method must keep working on that same older client. + install_fake_yamcs_client(include_basic_auth=False) + api_key_manifest = build_basic_auth_manifest( + auth_method=YAMCS_AUTH_METHOD_API_KEY, + enable_basic_auth=False, + ) + api_key_manifest["auth"] = {"type": "key", "key": "api-key-value"} + api_key_plugin = YamcsPluginFactory.create_from_config(api_key_manifest) + assert isinstance(api_key_plugin._build_credentials(), FakeAPIKeyCredentials) + + install_fake_yamcs_client() + print("Yamcs basic auth dependency handling passed.") + return True + + +def test_health_checker_matches_runtime_rules(): + """Manifest validation reports the same basic auth problems the plugin enforces.""" + print("Testing Yamcs basic auth manifest validation...") + + is_valid, errors = PluginHealthChecker.validate_plugin_manifest( + build_basic_auth_manifest(), YAMCS_PLUGIN_TYPE + ) + assert is_valid, f"Expected a valid manifest, got: {errors}" + + is_valid, errors = PluginHealthChecker.validate_plugin_manifest( + build_basic_auth_manifest(basic_auth_password=""), YAMCS_PLUGIN_TYPE + ) + assert is_valid is False + assert any("basic_auth_password" in error for error in errors) + + conflicting_manifest = build_basic_auth_manifest(auth_method=YAMCS_AUTH_METHOD_USERNAME_PASSWORD) + conflicting_manifest["auth"] = {"type": "username_password", "identity": "operator", "key": "secret"} + is_valid, errors = PluginHealthChecker.validate_plugin_manifest( + conflicting_manifest, YAMCS_PLUGIN_TYPE + ) + assert is_valid is False + assert any("Authorization header" in error for error in errors) + + # A referenced identity supplies both values at runtime, so inline values are optional. + identity_manifest = build_basic_auth_manifest( + basic_auth_username="", + basic_auth_password="", + basic_auth_identity_id="identity-123", + ) + is_valid, errors = PluginHealthChecker.validate_plugin_manifest( + identity_manifest, YAMCS_PLUGIN_TYPE + ) + assert is_valid, f"Expected an identity-backed manifest to be valid, got: {errors}" + + print("Yamcs basic auth manifest validation passed.") + return True + + +def test_basic_auth_password_is_treated_as_a_secret(): + """The proxy password routes through the same Key Vault handling as other secrets.""" + print("Testing Yamcs basic auth secret classification...") + + assert "basic_auth_password" in YAMCS_SENSITIVE_ADDITIONAL_FIELDS + yamcs_action = {"type": YAMCS_PLUGIN_TYPE} + assert _is_sensitive_plugin_additional_field(yamcs_action, "basic_auth_password") is True + # The username and toggle are configuration, not secrets. + assert _is_sensitive_plugin_additional_field(yamcs_action, "basic_auth_username") is False + assert _is_sensitive_plugin_additional_field(yamcs_action, "enable_basic_auth") is False + + print("Yamcs basic auth secret classification passed.") + return True + + +def test_proxy_identity_reference_contract(): + """The proxy credential has its own username/password identity reference.""" + print("Testing Yamcs proxy identity reference contract...") + + assert ACTION_PROXY_IDENTITY_FIELD == "basic_auth_identity_id" + assert ACTION_PROXY_IDENTITY_AUTH_TYPES == {"username_password"} + assert YAMCS_PLUGIN_TYPE in ACTION_PROXY_IDENTITY_TYPES + + referenced_action = build_basic_auth_manifest(basic_auth_identity_id="identity-123") + assert get_action_proxy_identity_reference_id(referenced_action) == "identity-123" + + assert get_action_proxy_identity_reference_id(build_basic_auth_manifest()) == "" + assert get_action_proxy_identity_reference_id({}) == "" + assert get_action_proxy_identity_reference_id(None) == "" + + # The proxy reference must be independent of the primary Yamcs credential reference. + primary_only = build_basic_auth_manifest() + primary_only["identity_id"] = "primary-identity" + assert get_action_proxy_identity_reference_id(primary_only) == "" + + print("Yamcs proxy identity reference contract passed.") + return True + + +def test_modal_payload_preserves_stored_credential_when_disabled(): + """Turning the toggle off must not blank the stored proxy credential on save. + + ``keyvault_plugin_save_helper`` skips falsy additionalFields values, so a payload that + sends an empty ``basic_auth_password`` replaces the stored Key Vault reference with an + empty string without deleting the secret. That orphans the secret and forces the user to + retype the password just to re-enable the toggle. Only selecting a reusable identity may + blank the inline fields. + """ + print("Testing Yamcs basic auth modal payload preservation...") + + stepper_source = (APP_DIR / "static" / "js" / "plugin_modal_stepper.js").read_text(encoding="utf-8") + + block_start = stepper_source.index("getYamcsConfiguration()") + block_end = stepper_source.index("const auth = {};", block_start) + configuration_block = stepper_source[block_start:block_end] + + # The credential fields are declared together and end at the next unrelated field. + credential_region = configuration_block[ + configuration_block.index("basic_auth_identity_id:"):configuration_block.index("max_rows:") + ] + + for field_name in ("basic_auth_identity_id", "basic_auth_username", "basic_auth_password"): + assert f"{field_name}:" in credential_region, f"{field_name} must be sent to the server" + + assert "basicAuthIdentity" in credential_region, ( + "Proxy credential fields should be blanked only when a reusable identity is selected" + ) + assert "enableBasicAuth" not in credential_region, ( + "Proxy credential fields must not be blanked when enable_basic_auth is false; " + "doing so drops the Key Vault reference and orphans the stored secret." + ) + + # The toggle itself still has to be sent. + assert "enable_basic_auth: enableBasicAuth" in configuration_block + + print("Yamcs basic auth modal payload preservation passed.") + return True + + +def test_app_version(): + """The application version is at least the release that added basic auth support.""" + print("Testing SimpleChat version floor...") + assert_app_version_at_least("0.261.012") + print("SimpleChat version floor passed.") + return True + + +if __name__ == "__main__": + tests = [ + test_basic_auth_normalization_defaults, + test_basic_auth_header_encoding, + test_basic_auth_compatibility_rules, + test_plugin_validation_rejects_incomplete_and_conflicting_configurations, + test_unauthenticated_yamcs_uses_basic_auth_credentials, + test_api_key_auth_sets_basic_header_on_session, + test_missing_basic_auth_client_support_is_reported, + test_health_checker_matches_runtime_rules, + test_basic_auth_password_is_treated_as_a_secret, + test_proxy_identity_reference_contract, + test_modal_payload_preserves_stored_credential_when_disabled, + test_app_version, + ] + + results = [] + for test in tests: + print(f"\nRunning {test.__name__}...") + try: + results.append(bool(test())) + except Exception as exc: + print(f"{test.__name__} failed: {exc}") + traceback.print_exc() + results.append(False) + + print(f"\nResults: {sum(results)}/{len(results)} tests passed") + sys.exit(0 if all(results) else 1) From cea31f145ef3f3ddbe49cb8ead710f851e4c4089 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Sat, 5 Sep 2026 10:49:15 -0400 Subject: [PATCH 2/3] Regenerate release notes index for v0.261.012 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/explanation/release-notes/index.md | 51 ++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/docs/explanation/release-notes/index.md b/docs/explanation/release-notes/index.md index 45db4c558..31c85ff2e 100644 --- a/docs/explanation/release-notes/index.md +++ b/docs/explanation/release-notes/index.md @@ -20,6 +20,8 @@ This page includes the latest release notes inline. Older release sections are s | Version | Page | | --- | --- | +| v0.261.012 | [Release notes index]({{ '/explanation/release_notes/' | relative_url }}) | +| v0.261.011 | [Release notes index]({{ '/explanation/release_notes/' | relative_url }}) | | v0.261.010 | [Release notes index]({{ '/explanation/release_notes/' | relative_url }}) | | v0.261.009 | [Release notes index]({{ '/explanation/release_notes/' | relative_url }}) | | v0.261.007 | [Release notes index]({{ '/explanation/release_notes/' | relative_url }}) | @@ -77,7 +79,7 @@ This page includes the latest release notes inline. Older release sections are s ## Latest release notes -### **(v0.261.010)** +### **(v0.261.012)** #### New Features @@ -90,6 +92,53 @@ This page includes the latest release notes inline. Older release sections are s * **Test Yamcs Connection** exercises the proxy credential and distinguishes a proxy rejection from a Yamcs rejection. * (Ref: `functions_yamcs_operations.py`, `yamcs_plugin.py`, `functions_workspace_identities.py`, `plugin_health_checker.py`, `route_backend_plugins.py`, `_plugin_modal.html`, `plugin_modal_stepper.js`, `test_yamcs_basic_auth.py`, [Yamcs Action](features/YAMCS_ACTION.md), [#1435](https://github.com/microsoft/simplechat/issues/1435)) +### **(v0.261.011)** + +#### Bug Fixes + +* **Redis Connection Test No Longer Returns Credential Errors To The Browser** + * Restored the hardening that a refactor had dropped: when the admin Redis connection test fails while resolving credentials, the details are logged under `[REDIS_TEST]` and the browser receives a generic message instead of the raw exception, which could carry Key Vault secret names, vault URIs, or token details. + * Validation problems such as a missing host name or access key are still returned directly, because those messages are generated by SimpleChat and are what the admin needs to fix the form. + * (Ref: `route_backend_settings.py`, `test_redis_client_factory.py`) + +* **Deployer Redis Kind Detection Matches The Full Host Name Suffix** + * The postprovision fallback that infers the Redis offering from a host name used a substring check, so a host name that merely contained `.redis.azure.net` anywhere could be misread as Azure Managed Redis and configured with the wrong port. + * It now matches the full suffix, consistent with the application's own detection. + * (Ref: `deployers/bicep/postconfig.py`) + +### **(v0.261.010)** + +#### New Features + +* **Azure Managed Redis Support** + * SimpleChat now connects to Azure Managed Redis as well as Azure Cache for Redis, ahead of the September 30, 2028 retirement of the Azure Cache for Redis Basic, Standard, and Premium tiers. + * The two services listen on different TLS ports, so SimpleChat resolves the port from the host name suffix: `*..redis.azure.net` connects on port 10000 and `*.redis.cache.windows.net` (plus the Azure Government and 21Vianet equivalents) on port 6380. + * New **Redis Service** and **Redis Port** settings let an administrator state the service explicitly when a custom DNS name or private endpoint hides the Azure suffix. Existing deployments are unaffected: unrecognized host names keep the previous Azure Cache for Redis behavior. + * The Redis Metrics panel now reports which service and port were resolved, and whether that came from detection or an explicit setting. + * Both services are supported because Azure Managed Redis is not available in Azure Government or Azure operated by 21Vianet. + * (Ref: `functions_redis_client.py`, `app_settings_cache.py`, `app.py`, `redis-caching.html`, [Azure Managed Redis Support](features/AZURE_MANAGED_REDIS_SUPPORT.md)) + +* **Redis Deployment Uses Azure Managed Redis** + * The Bicep deployer now provisions Azure Managed Redis `Balanced_B0` with high availability enabled, Microsoft's documented replacement for the Azure Cache for Redis Standard C0 it previously deployed. That is twice the memory for less cost. + * A new `redisCacheKind` parameter still deploys classic Azure Cache for Redis for sovereign clouds where Azure Managed Redis is unavailable. + * The database is created with the `NoCluster` clustering policy. The service default is `OSSCluster`, which requires a cluster-aware Redis client that SimpleChat does not use. + * Managed identity deployments disable access keys and grant the web app the built-in `default` access policy on the Azure Managed Redis database. + * (Ref: `deployers/bicep/modules/redisCache.bicep`, `setPermissions.bicep`, `setNativeWebAppPermissions.bicep`, `postconfig.py`, `deployers/version.txt`) + +#### Bug Fixes + +* **Redis Managed Identity Tokens Refresh On Open Connections** + * Redis managed identity authentication now uses the `redis-entraid` streaming credential provider, which renews the Microsoft Entra token in the background and re-issues `AUTH` on connections that are already open. Previously credentials were supplied only at connect time, so a long-lived pooled connection relied on the server dropping it once the token expired. + * The shared application cache and Flask session storage each get their own provider instance, because the provider holds a single re-authentication callback slot and sharing one would leave the first client's connections unrefreshed. + * The admin "Test Redis Connection" button uses a connect-time-only provider, so repeated clicks no longer accumulate background threads, event loops, and recurring token requests. + * The package is imported defensively, falling back to the previous in-repo provider if it is missing, so an application updated without reinstalling requirements still starts. + * (Ref: `functions_redis_client.py`, `requirements.txt`, `test_redis_entra_token_auth.py`) + +* **Redis Connection Test Uses The Same Token Scope As The Application** + * The admin Redis connection test acquired its managed identity token from the legacy cache infrastructure endpoint while the running application used the `https://redis.azure.com/.default` scope, so a passing test did not prove the application could connect. + * The test now builds its client through the same factory as the application, including service and port resolution. + * (Ref: `route_backend_settings.py`) + ### **(v0.261.009)** #### Bug Fixes From a65e82c01e33e3c9c94edbb90cd1e0998eedc72e Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Sat, 5 Sep 2026 11:15:53 -0400 Subject: [PATCH 3/3] Potential fix for pull request finding 'CodeQL / Information exposure through an exception' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- application/single_app/route_backend_plugins.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/application/single_app/route_backend_plugins.py b/application/single_app/route_backend_plugins.py index a40e5ca5a..d53f5e2f7 100644 --- a/application/single_app/route_backend_plugins.py +++ b/application/single_app/route_backend_plugins.py @@ -2713,7 +2713,11 @@ def test_yamcs_connection(): ACTION_ADDITIONAL_SECRET_SOURCES, ) except ValueError as exc: - return jsonify({'success': False, 'error': str(exc)}), 400 + logging.warning("Failed to resolve Yamcs basic auth password for action test: %s", exc) + return jsonify({ + 'success': False, + 'error': 'Invalid Yamcs authentication configuration.' + }), 400 if not basic_auth_username: return jsonify({'success': False, 'error': 'A username is required for Yamcs HTTP Basic authentication.'}), 400