diff --git a/application/single_app/config.py b/application/single_app/config.py index 7adc43bf2..70f8cf21c 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -96,7 +96,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.250.216" +VERSION = "0.250.217" IS_DEVELOPMENT = is_development_env_enabled() SESSION_COOKIE_SAMESITE = os.getenv('SESSION_COOKIE_SAMESITE', 'Lax') diff --git a/application/single_app/functions_action_connection_tests.py b/application/single_app/functions_action_connection_tests.py new file mode 100644 index 000000000..dce1dc27d --- /dev/null +++ b/application/single_app/functions_action_connection_tests.py @@ -0,0 +1,682 @@ +# functions_action_connection_tests.py +"""Connection tests for configurable SimpleChat action (plugin) types. + +Each public tester takes an already hydrated action manifest, authenticates with the +configured credentials, and performs one lightweight read against the configured +resource. Testers never raise; they return a normalized result dictionary so the +calling route can jsonify it directly. + +Secrets are never echoed back to the browser. Every failure message is passed through +``sanitize_connection_error`` before it leaves this module. +""" + +import asyncio +import base64 +import logging +import re +from datetime import timedelta +from typing import Any, Dict, List, Optional + +import requests + +from functions_appinsights import log_event +from functions_azure_maps import ( + AZURE_MAPS_DEFAULT_ENDPOINT, + AZURE_MAPS_DEFAULT_LANGUAGE, + AZURE_MAPS_DEFAULT_TILESET_ID, + AZURE_MAPS_DEFAULT_VIEW, + AZURE_MAPS_TILE_API_VERSION, +) +from functions_mcp_operations import MCP_CUSTOM_HEADERS_FIELD + +ACTION_CONNECTION_TEST_MAX_TIMEOUT_SECONDS = 20 +ACTION_CONNECTION_TEST_MIN_TIMEOUT_SECONDS = 1 +ACTION_CONNECTION_TEST_DEFAULT_TIMEOUT_SECONDS = 15 + +REDACTED_PLACEHOLDER = "[redacted]" + +_SENSITIVE_ASSIGNMENT_PATTERN = re.compile( + r"(?i)\b(password|pwd|passphrase|private[_\s-]*key|secret|token|api[_\s-]*key|accountkey|sharedaccesssignature|sig)\b\s*[=:]\s*[^\s,;&'\"]+" +) +_AUTHORIZATION_HEADER_PATTERN = re.compile(r"(?i)\b(bearer|basic)\s+[A-Za-z0-9._~+/=-]{8,}") + +_MISSING_PACKAGE_MESSAGES = { + "snowflake": "The Snowflake Connector for Python is not installed in this container image. Rebuild the image with snowflake-connector-python to test Snowflake actions.", + "tableau": "The tableauserverclient package is not installed in this container image. Rebuild the image with tableauserverclient to test Tableau actions.", + "log_analytics": "The azure-monitor-query package is not installed in this container image. Rebuild the image with azure-monitor-query to test Log Analytics actions.", +} + + +def resolve_test_timeout(raw_timeout: Any, default_timeout: int = ACTION_CONNECTION_TEST_DEFAULT_TIMEOUT_SECONDS) -> int: + """Clamp a caller-supplied timeout into the allowed action connection test range.""" + try: + timeout = int(raw_timeout) + except (TypeError, ValueError): + timeout = default_timeout + if timeout < ACTION_CONNECTION_TEST_MIN_TIMEOUT_SECONDS: + timeout = ACTION_CONNECTION_TEST_MIN_TIMEOUT_SECONDS + return min(timeout, ACTION_CONNECTION_TEST_MAX_TIMEOUT_SECONDS) + + +def collect_manifest_secret_values(manifest: Optional[Dict[str, Any]]) -> List[str]: + """Collect every literal secret value stored in a manifest so it can be redacted.""" + if not isinstance(manifest, dict): + return [] + + secret_values: List[str] = [] + + auth = manifest.get("auth") if isinstance(manifest.get("auth"), dict) else {} + for auth_field in ("key", "identity", "tenantId"): + auth_value = auth.get(auth_field) + if isinstance(auth_value, str) and len(auth_value.strip()) >= 4: + secret_values.append(auth_value.strip()) + + additional_fields = manifest.get("additionalFields") if isinstance(manifest.get("additionalFields"), dict) else {} + passphrase = additional_fields.get("private_key_passphrase") + if isinstance(passphrase, str) and len(passphrase.strip()) >= 4: + secret_values.append(passphrase.strip()) + + custom_headers = additional_fields.get(MCP_CUSTOM_HEADERS_FIELD) + if isinstance(custom_headers, dict): + for header_value in custom_headers.values(): + if isinstance(header_value, str) and len(header_value.strip()) >= 4: + secret_values.append(header_value.strip()) + + # Redact the longest values first so overlapping secrets do not leave fragments behind. + return sorted(set(secret_values), key=len, reverse=True) + + +def sanitize_connection_error(error: Any, manifest: Optional[Dict[str, Any]] = None, max_length: int = 400) -> str: + """Return an error message safe to show in the browser, with credentials removed.""" + message = str(error or "").strip() + if not message: + return "The connection test failed for an unknown reason." + + for secret_value in collect_manifest_secret_values(manifest): + message = message.replace(secret_value, REDACTED_PLACEHOLDER) + encoded_secret = base64.b64encode(secret_value.encode("utf-8", errors="ignore")).decode("ascii") + if len(encoded_secret) >= 8: + message = message.replace(encoded_secret, REDACTED_PLACEHOLDER) + + message = _SENSITIVE_ASSIGNMENT_PATTERN.sub(lambda match: f"{match.group(1)}={REDACTED_PLACEHOLDER}", message) + message = _AUTHORIZATION_HEADER_PATTERN.sub(lambda match: f"{match.group(1)} {REDACTED_PLACEHOLDER}", message) + message = re.sub(r"\s+", " ", message).strip() + + if len(message) > max_length: + message = f"{message[:max_length].rstrip()}..." + return message + + +def build_success_result(message: str, **details: Any) -> Dict[str, Any]: + """Build a normalized successful action connection test result.""" + return { + "success": True, + "message": message, + "status": 200, + "details": {key: value for key, value in details.items() if value not in (None, "")}, + } + + +def build_failure_result(error: str, status: int = 400, **details: Any) -> Dict[str, Any]: + """Build a normalized failed action connection test result.""" + return { + "success": False, + "error": error, + "status": status, + "details": {key: value for key, value in details.items() if value not in (None, "")}, + } + + +def _missing_package_result(package_key: str) -> Dict[str, Any]: + """Build a friendly failure result for an uninstalled optional dependency.""" + return build_failure_result(_MISSING_PACKAGE_MESSAGES[package_key], status=501) + + +def _log_connection_test(plugin_type: str, result: Dict[str, Any], extra: Optional[Dict[str, Any]] = None) -> None: + """Emit low-sensitivity telemetry for an action connection test outcome.""" + context = {"plugin_type": plugin_type, "succeeded": bool(result.get("success"))} + context.update(extra or {}) + if result.get("success"): + log_event(f"[ACTION_TEST] {plugin_type} connection test succeeded", extra=context, level=logging.INFO) + return + context["status"] = result.get("status") + log_event(f"[ACTION_TEST] {plugin_type} connection test failed", extra=context, level=logging.WARNING) + + +def _http_status_failure(status_code: int, auth_hint: str, not_found_hint: str, fallback_hint: str) -> Dict[str, Any]: + """Map an upstream HTTP status onto an actionable failure result.""" + if status_code in (401, 403): + return build_failure_result(auth_hint, status=403, http_status=status_code) + if status_code == 404: + return build_failure_result(not_found_hint, status=404, http_status=status_code) + return build_failure_result(f"{fallback_hint} (HTTP {status_code}).", status=400, http_status=status_code) + + +def _build_openapi_probe_request(manifest: Dict[str, Any]) -> Dict[str, Any]: + """Build headers, query params, and basic auth for the OpenAPI base URL probe.""" + from semantic_kernel_plugins.openapi_plugin_factory import OpenApiPluginFactory + + normalized_auth = OpenApiPluginFactory._extract_auth_config(manifest) or {} + additional_fields = manifest.get("additionalFields") if isinstance(manifest.get("additionalFields"), dict) else {} + auth_type = str(normalized_auth.get("type") or "none").strip().lower() + + headers: Dict[str, str] = {"Accept": "*/*"} + params: Dict[str, str] = {} + basic_auth = None + + if auth_type == "bearer": + token = str(normalized_auth.get("token") or "").strip() + if token: + headers["Authorization"] = f"Bearer {token}" + elif auth_type == "basic": + username = str(normalized_auth.get("username") or "") + password = str(normalized_auth.get("password") or "") + if username or password: + basic_auth = (username, password) + elif auth_type in ("key", "api_key"): + key_value = str(normalized_auth.get("key") or normalized_auth.get("value") or "").strip() + key_name = str( + additional_fields.get("api_key_name") + or normalized_auth.get("name") + or "X-API-Key" + ).strip() or "X-API-Key" + key_location = str( + additional_fields.get("api_key_location") + or normalized_auth.get("location") + or "header" + ).strip().lower() + if key_value: + if key_location == "query": + params[key_name] = key_value + else: + headers[key_name] = key_value + + return {"headers": headers, "params": params, "basic_auth": basic_auth, "auth_type": auth_type} + + +def test_openapi_connection(manifest: Dict[str, Any]) -> Dict[str, Any]: + """Validate an OpenAPI action by parsing its spec and probing the authenticated base URL.""" + from semantic_kernel_plugins.openapi_plugin_factory import OpenApiPluginFactory + + additional_fields = manifest.get("additionalFields") if isinstance(manifest.get("additionalFields"), dict) else {} + base_url = str(manifest.get("endpoint") or additional_fields.get("base_url") or "").strip().rstrip("/") + timeout = resolve_test_timeout(additional_fields.get("timeout")) + + if not base_url: + return build_failure_result("A base URL is required before testing an OpenAPI action.") + + try: + plugin = OpenApiPluginFactory.create_from_config( + { + "base_url": base_url, + "endpoint": base_url, + "auth": manifest.get("auth") or {}, + "additionalFields": additional_fields, + "openapi_spec_content": additional_fields.get("openapi_spec_content"), + "openapi_source_type": additional_fields.get("openapi_source_type"), + "openapi_file_id": additional_fields.get("openapi_file_id"), + "openapi_spec_path": additional_fields.get("openapi_spec_path"), + } + ) + operations = plugin.get_available_operations() or [] + except Exception as exc: + result = build_failure_result( + f"The OpenAPI specification could not be loaded: {sanitize_connection_error(exc, manifest)}" + ) + _log_connection_test("openapi", result, {"stage": "spec_parse"}) + return result + + probe = _build_openapi_probe_request(manifest) + try: + response = requests.get( + base_url, + headers=probe["headers"], + params=probe["params"] or None, + auth=probe["basic_auth"], + timeout=timeout, + allow_redirects=True, + ) + except requests.RequestException as exc: + result = build_failure_result( + f"The API base URL could not be reached: {sanitize_connection_error(exc, manifest)}", + status=502, + ) + _log_connection_test("openapi", result, {"stage": "base_url_probe"}) + return result + + operation_count = len(operations) + if response.status_code in (401, 403): + result = build_failure_result( + f"The API base URL responded with HTTP {response.status_code}. The specification parsed correctly, " + "so verify the configured authentication credentials.", + status=403, + http_status=response.status_code, + operation_count=operation_count, + ) + _log_connection_test("openapi", result, {"stage": "base_url_probe", "http_status": response.status_code}) + return result + + result = build_success_result( + f"Specification parsed with {operation_count} operation{'' if operation_count == 1 else 's'}. " + f"The base URL responded with HTTP {response.status_code}.", + operation_count=operation_count, + http_status=response.status_code, + auth_type=probe["auth_type"], + ) + _log_connection_test("openapi", result, {"operation_count": operation_count, "http_status": response.status_code}) + return result + + +def test_azure_maps_connection(manifest: Dict[str, Any]) -> Dict[str, Any]: + """Validate an Azure Maps action by fetching a single base road tile.""" + auth = manifest.get("auth") if isinstance(manifest.get("auth"), dict) else {} + subscription_key = str(auth.get("key") or "").strip() + endpoint = str(manifest.get("endpoint") or AZURE_MAPS_DEFAULT_ENDPOINT).strip().rstrip("/") + + if not subscription_key: + return build_failure_result("An Azure Maps subscription key is required before testing this action.") + + try: + response = requests.get( + f"{endpoint}/map/tile", + params={ + "api-version": AZURE_MAPS_TILE_API_VERSION, + "tilesetId": AZURE_MAPS_DEFAULT_TILESET_ID, + "zoom": 0, + "x": 0, + "y": 0, + "tileSize": "256", + "language": AZURE_MAPS_DEFAULT_LANGUAGE, + "view": AZURE_MAPS_DEFAULT_VIEW, + "subscription-key": subscription_key, + }, + timeout=ACTION_CONNECTION_TEST_DEFAULT_TIMEOUT_SECONDS, + ) + except requests.RequestException as exc: + result = build_failure_result( + f"Azure Maps could not be reached: {sanitize_connection_error(exc, manifest)}", + status=502, + ) + _log_connection_test("azure_maps_openlayers", result) + return result + + if response.status_code == 200: + content_type = str(response.headers.get("Content-Type") or "").lower() + if not content_type.startswith("image/"): + result = build_failure_result( + "Azure Maps returned a non-image response for the test tile. Confirm the endpoint is an Azure Maps account URL." + ) + _log_connection_test("azure_maps_openlayers", result) + return result + + result = build_success_result( + f"Successfully retrieved an Azure Maps {AZURE_MAPS_DEFAULT_TILESET_ID} tile with the configured subscription key.", + tileset_id=AZURE_MAPS_DEFAULT_TILESET_ID, + http_status=response.status_code, + ) + _log_connection_test("azure_maps_openlayers", result) + return result + + result = _http_status_failure( + response.status_code, + "Azure Maps rejected the subscription key. Verify the key and confirm it belongs to the target Azure Maps account.", + "Azure Maps could not find the tile endpoint. Verify the configured Azure Maps endpoint URL.", + "The Azure Maps tile request failed", + ) + _log_connection_test("azure_maps_openlayers", result, {"http_status": response.status_code}) + return result + + +def test_blob_storage_connection(manifest: Dict[str, Any]) -> Dict[str, Any]: + """Validate a Blob Storage action by reading container properties and listing one blob.""" + from azure.core.exceptions import ClientAuthenticationError, ResourceNotFoundError + from semantic_kernel_plugins.blob_storage_plugin import BlobStoragePlugin + + try: + plugin = BlobStoragePlugin(manifest) + except ValueError as exc: + result = build_failure_result(sanitize_connection_error(exc, manifest)) + _log_connection_test("blob_storage", result, {"stage": "configuration"}) + return result + except Exception as exc: + result = build_failure_result( + f"The Blob Storage client could not be created: {sanitize_connection_error(exc, manifest)}" + ) + _log_connection_test("blob_storage", result, {"stage": "client"}) + return result + + try: + container_properties = plugin.container_client.get_container_properties() + except ResourceNotFoundError: + result = build_failure_result( + f"Container '{plugin.container_name}' was not found in the configured storage account.", + status=404, + ) + _log_connection_test("blob_storage", result, {"stage": "container_read"}) + return result + except ClientAuthenticationError as exc: + result = build_failure_result( + f"Storage authentication failed: {sanitize_connection_error(exc, manifest)}", + status=403, + ) + _log_connection_test("blob_storage", result, {"stage": "container_read"}) + return result + except Exception as exc: + result = build_failure_result( + f"The Blob Storage container could not be read: {sanitize_connection_error(exc, manifest)}" + ) + _log_connection_test("blob_storage", result, {"stage": "container_read"}) + return result + + try: + blob_pages = plugin.container_client.list_blobs( + name_starts_with=plugin.blob_prefix or None, + results_per_page=1, + ).by_page() + listed_blobs = list(next(blob_pages, [])) + except Exception as exc: + result = build_failure_result( + f"The Blob Storage container could not be listed: {sanitize_connection_error(exc, manifest)}", + status=403, + ) + _log_connection_test("blob_storage", result, {"stage": "container_list"}) + return result + + prefix_label = f" under prefix '{plugin.blob_prefix}'" if plugin.blob_prefix else "" + blob_label = "at least one blob" if listed_blobs else "no blobs yet" + result = build_success_result( + f"Connected to container '{plugin.container_name}' and found {blob_label}{prefix_label}.", + container_name=plugin.container_name, + blob_prefix=plugin.blob_prefix, + last_modified=str(getattr(container_properties, "last_modified", "") or ""), + ) + _log_connection_test("blob_storage", result) + return result + + +def test_databricks_connection(manifest: Dict[str, Any]) -> Dict[str, Any]: + """Validate a Databricks action by reading the configured SQL warehouse.""" + from semantic_kernel_plugins.databricks_plugin_factory import DatabricksPluginFactory + + try: + plugin = DatabricksPluginFactory.create_from_config(manifest) + except ValueError as exc: + result = build_failure_result(sanitize_connection_error(exc, manifest)) + _log_connection_test("databricks", result, {"stage": "configuration"}) + return result + except Exception as exc: + result = build_failure_result( + f"The Databricks action configuration is invalid: {sanitize_connection_error(exc, manifest)}" + ) + _log_connection_test("databricks", result, {"stage": "configuration"}) + return result + + timeout = resolve_test_timeout(plugin.timeout) + try: + headers = plugin._headers() + except Exception as exc: + result = build_failure_result( + f"A Databricks access token could not be acquired: {sanitize_connection_error(exc, manifest)}", + status=403, + ) + _log_connection_test("databricks", result, {"stage": "token"}) + return result + + try: + response = requests.get( + f"{plugin.endpoint}/api/2.0/sql/warehouses/{plugin.warehouse_id}", + headers=headers, + timeout=timeout, + ) + except requests.RequestException as exc: + result = build_failure_result( + f"The Databricks workspace could not be reached: {sanitize_connection_error(exc, manifest)}", + status=502, + ) + _log_connection_test("databricks", result, {"stage": "warehouse_read"}) + return result + + if response.status_code != 200: + result = _http_status_failure( + response.status_code, + "Databricks rejected the configured credentials. Verify the token, service principal, or managed identity access to this workspace.", + f"SQL warehouse '{plugin.warehouse_id}' was not found in this Databricks workspace.", + "The Databricks warehouse lookup failed", + ) + _log_connection_test("databricks", result, {"stage": "warehouse_read", "http_status": response.status_code}) + return result + + try: + warehouse = response.json() or {} + except ValueError: + warehouse = {} + + warehouse_name = str(warehouse.get("name") or plugin.warehouse_id) + warehouse_state = str(warehouse.get("state") or "UNKNOWN") + result = build_success_result( + f"Connected to Databricks SQL warehouse '{warehouse_name}' (state: {warehouse_state}).", + warehouse_id=plugin.warehouse_id, + warehouse_name=warehouse_name, + warehouse_state=warehouse_state, + ) + _log_connection_test("databricks", result, {"warehouse_state": warehouse_state}) + return result + + +def test_log_analytics_connection(manifest: Dict[str, Any]) -> Dict[str, Any]: + """Validate a Log Analytics action by running a trivial KQL query against the workspace.""" + try: + from azure.monitor.query import LogsQueryStatus + except ImportError: + return _missing_package_result("log_analytics") + + from semantic_kernel_plugins.log_analytics_plugin import LogAnalyticsPlugin + + additional_fields = manifest.get("additionalFields") if isinstance(manifest.get("additionalFields"), dict) else {} + workspace_id = str(additional_fields.get("workspaceId") or "").strip() + if not workspace_id: + return build_failure_result("A Log Analytics workspace ID is required before testing this action.") + + try: + plugin = LogAnalyticsPlugin(manifest) + except Exception as exc: + result = build_failure_result( + f"The Log Analytics client could not be created: {sanitize_connection_error(exc, manifest)}" + ) + _log_connection_test("log_analytics", result, {"stage": "client"}) + return result + + if not plugin._client: + result = build_failure_result( + "A Log Analytics query client could not be created. Verify the workspace ID, cloud, and authentication settings." + ) + _log_connection_test("log_analytics", result, {"stage": "client"}) + return result + + try: + response = plugin._client.query_workspace( + workspace_id=workspace_id, + query="print TestConnection = 1", + timespan=timedelta(minutes=5), + ) + except Exception as exc: + result = build_failure_result( + f"The Log Analytics workspace query failed: {sanitize_connection_error(exc, manifest)}" + ) + _log_connection_test("log_analytics", result, {"stage": "query"}) + return result + + query_status = getattr(response, "status", None) + if query_status == LogsQueryStatus.FAILURE: + partial_error = getattr(response, "partial_error", None) + result = build_failure_result( + f"The Log Analytics workspace rejected the test query: {sanitize_connection_error(partial_error or 'Unknown query failure.', manifest)}" + ) + _log_connection_test("log_analytics", result, {"stage": "query"}) + return result + + cloud = str(additional_fields.get("cloud") or "public") + result = build_success_result( + f"Successfully queried Log Analytics workspace {workspace_id} in the {cloud} cloud.", + workspace_id=workspace_id, + cloud=cloud, + query_status=str(query_status or "SUCCESS"), + ) + _log_connection_test("log_analytics", result, {"cloud": cloud}) + return result + + +def test_mcp_connection(manifest: Dict[str, Any]) -> Dict[str, Any]: + """Validate an MCP action by initializing a session and listing the server's tools.""" + from semantic_kernel_plugins.mcp_plugin_factory import McpPluginFactory + + try: + probe_result = asyncio.run(McpPluginFactory.probe_server_from_config(manifest)) + except Exception as exc: + result = build_failure_result( + f"The MCP server connection failed: {sanitize_connection_error(exc, manifest)}", + status=502, + ) + _log_connection_test("mcp", result) + return result + + if not isinstance(probe_result, dict): + result = build_failure_result("The MCP server returned an unexpected probe response.") + _log_connection_test("mcp", result) + return result + + tools = probe_result.get("tools") or [] + transport = str(probe_result.get("transport") or "") + auth_method = str(probe_result.get("auth_method") or "") + warnings = [str(warning) for warning in (probe_result.get("warnings") or []) if str(warning).strip()] + + message = ( + f"Connected over {transport or 'the configured transport'} and listed " + f"{len(tools)} tool{'' if len(tools) == 1 else 's'}." + ) + if warnings: + message = f"{message} {' '.join(warnings)}" + + result = build_success_result( + message, + transport=transport, + auth_method=auth_method, + tool_count=len(tools), + ) + result["warnings"] = warnings + _log_connection_test("mcp", result, {"transport": transport, "tool_count": len(tools)}) + return result + + +def test_snowflake_connection(manifest: Dict[str, Any]) -> Dict[str, Any]: + """Validate a Snowflake action by opening a session and reading the account version.""" + from semantic_kernel_plugins.snowflake_plugin_factory import SnowflakePluginFactory + + try: + plugin = SnowflakePluginFactory.create_from_config(manifest) + except ValueError as exc: + result = build_failure_result(sanitize_connection_error(exc, manifest)) + _log_connection_test("snowflake", result, {"stage": "configuration"}) + return result + except Exception as exc: + result = build_failure_result( + f"The Snowflake action configuration is invalid: {sanitize_connection_error(exc, manifest)}" + ) + _log_connection_test("snowflake", result, {"stage": "configuration"}) + return result + + connection = None + cursor = None + try: + connection = plugin._connect() + cursor = connection.cursor() + cursor.execute("SELECT CURRENT_VERSION()") + row = cursor.fetchone() + snowflake_version = str(row[0]) if row else "unknown" + except ImportError: + result = _missing_package_result("snowflake") + _log_connection_test("snowflake", result, {"stage": "driver"}) + return result + except Exception as exc: + result = build_failure_result( + f"The Snowflake connection failed: {sanitize_connection_error(exc, manifest)}" + ) + _log_connection_test("snowflake", result, {"stage": "connect"}) + return result + finally: + if cursor is not None: + try: + cursor.close() + except Exception: + log_event( + "[ACTION_TEST] Snowflake test cursor could not be closed cleanly.", + level=logging.DEBUG, + debug_only=True, + ) + if connection is not None: + try: + connection.close() + except Exception: + log_event( + "[ACTION_TEST] Snowflake test connection could not be closed cleanly.", + level=logging.DEBUG, + debug_only=True, + ) + + result = build_success_result( + f"Connected to Snowflake account '{plugin.account}' using warehouse '{plugin.warehouse}' (version {snowflake_version}).", + account=plugin.account, + warehouse=plugin.warehouse, + snowflake_version=snowflake_version, + ) + _log_connection_test("snowflake", result) + return result + + +def test_tableau_connection(manifest: Dict[str, Any]) -> Dict[str, Any]: + """Validate a Tableau action by signing in to the configured server and site.""" + from semantic_kernel_plugins.tableau_plugin_factory import TableauPluginFactory + + try: + plugin = TableauPluginFactory.create_from_config(manifest) + except ValueError as exc: + result = build_failure_result(sanitize_connection_error(exc, manifest)) + _log_connection_test("tableau", result, {"stage": "configuration"}) + return result + except Exception as exc: + result = build_failure_result( + f"The Tableau action configuration is invalid: {sanitize_connection_error(exc, manifest)}" + ) + _log_connection_test("tableau", result, {"stage": "configuration"}) + return result + + try: + plugin._require_tsc() + except RuntimeError: + result = _missing_package_result("tableau") + _log_connection_test("tableau", result, {"stage": "driver"}) + return result + + try: + server = plugin._create_server() + tableau_auth = plugin._get_tableau_auth() + with server.auth.sign_in(tableau_auth): + server_version = str(getattr(server, "version", "") or "unknown") + site_id = str(getattr(server, "site_id", "") or "") + except Exception as exc: + result = build_failure_result( + f"The Tableau sign-in failed: {sanitize_connection_error(exc, manifest)}", + status=403, + ) + _log_connection_test("tableau", result, {"stage": "sign_in"}) + return result + + site_label = plugin.site_content_url or "the default site" + result = build_success_result( + f"Signed in to Tableau at {plugin.endpoint} on {site_label} (API version {server_version}).", + server_version=server_version, + site_content_url=plugin.site_content_url, + site_id_present=bool(site_id), + ) + _log_connection_test("tableau", result) + return result diff --git a/application/single_app/route_backend_plugins.py b/application/single_app/route_backend_plugins.py index 234dd62fa..309a80753 100644 --- a/application/single_app/route_backend_plugins.py +++ b/application/single_app/route_backend_plugins.py @@ -52,7 +52,6 @@ resolve_secret_reference_for_context, SecretReturnType, redact_plugin_secret_values, - retrieve_secret_from_key_vault_by_full_name, ui_trigger_word, validate_secret_name_dynamic, ) @@ -66,6 +65,16 @@ log_action_deletion, ) from functions_azure_maps import AZURE_MAPS_DEFAULT_ENDPOINT, AZURE_MAPS_PLUGIN_TYPE +from functions_action_connection_tests import ( + test_azure_maps_connection, + test_blob_storage_connection, + test_databricks_connection, + test_log_analytics_connection, + test_mcp_connection, + test_openapi_connection, + test_snowflake_connection, + test_tableau_connection, +) from functions_blob_storage_operations import ( BLOB_STORAGE_PLUGIN_TYPE, derive_blob_endpoint_from_connection_string, @@ -774,20 +783,45 @@ def _hydrate_sql_test_identity(data, existing_plugin, user_id): ) -def _resolve_secret_value_for_plugin_test(value, field_name, plugin_label='plugin'): - """Resolve a Key Vault reference for plugin test-connection flows.""" +ACTION_CONNECTION_TEST_AUTH_SECRET_FIELDS = ('key', 'identity', 'tenantId') +ACTION_CONNECTION_TEST_ADDITIONAL_SECRET_FIELDS = ('private_key_passphrase',) +# Secret reference sources must match how keyvault_plugin_get_helper stored each field. +ACTION_AUTH_SECRET_SOURCES = {"action"} +ACTION_ADDITIONAL_SECRET_SOURCES = {"action-addset"} + + +def _resolve_secret_value_for_action_test(value, field_name, plugin_label, scope_value, scope, allowed_sources): + """Resolve a Key Vault reference for an action test, bound to the action's own scope. + + Callers must pass an explicit scope and the allowed reference sources for the field being + resolved. A caller-supplied reference name is untrusted input, so resolving it without a + scope check would let an authenticated user read another user's, group's, or global + action's secret and forward it to a caller-controlled endpoint. + + Sources mirror how the action was stored by ``keyvault_plugin_get_helper``: ``auth.*`` + fields use ``action`` while ``additionalFields.*`` values and MCP custom headers use + ``action-addset``. + """ if not isinstance(value, str) or not value: return value if not validate_secret_name_dynamic(value): return value - resolved_value = retrieve_secret_from_key_vault_by_full_name(value) - if validate_secret_name_dynamic(resolved_value): - raise ValueError(f"Unable to resolve stored Key Vault secret for {plugin_label} field '{field_name}'.") - return resolved_value + if not scope_value or not scope: + raise ValueError( + f"Unable to determine the Key Vault scope for {plugin_label} field '{field_name}'." + ) + + return resolve_secret_reference_for_context( + value, + scope_value=scope_value, + scope=scope, + allowed_sources=allowed_sources, + context_label=f"{plugin_label} field '{field_name}'", + ) -def _hydrate_mcp_custom_headers_for_test(discovery_manifest, existing_plugin): +def _hydrate_mcp_custom_headers_for_test(discovery_manifest, existing_plugin, scope_value, scope): """Resolve or preserve MCP custom header values for transient discovery calls.""" additional_fields = discovery_manifest.get('additionalFields') if isinstance(discovery_manifest.get('additionalFields'), dict) else {} custom_headers = additional_fields.get(MCP_CUSTOM_HEADERS_FIELD, {}) @@ -808,10 +842,13 @@ def _hydrate_mcp_custom_headers_for_test(discovery_manifest, existing_plugin): raise ValueError(f"Stored MCP custom header '{header_name}' could not be resolved. Re-enter the header value.") if not resolved_header_value: continue - hydrated_headers[header_name] = _resolve_secret_value_for_plugin_test( + hydrated_headers[header_name] = _resolve_secret_value_for_action_test( resolved_header_value, f"custom_headers.{header_name}", - plugin_label='MCP', + 'MCP', + scope_value, + scope, + ACTION_ADDITIONAL_SECRET_SOURCES, ) additional_fields[MCP_CUSTOM_HEADERS_FIELD] = hydrated_headers @@ -819,21 +856,19 @@ def _hydrate_mcp_custom_headers_for_test(discovery_manifest, existing_plugin): def _resolve_secret_value_for_sql_test(value, field_name, scope_value=None, scope="user"): - """Resolve a Key Vault reference for SQL test-connection flows.""" - if not isinstance(value, str) or not value: - return value - if not validate_secret_name_dynamic(value): - return value + """Resolve a Key Vault reference for SQL test-connection flows. - if scope_value is None: - return _resolve_secret_value_for_plugin_test(value, field_name, plugin_label='SQL') - - return resolve_secret_reference_for_context( + SQL secrets live in additionalFields (connection_string, password), so they carry the + action-addset source. Routing through the shared action-test resolver keeps the + fail-closed behavior when a scope cannot be determined. + """ + return _resolve_secret_value_for_action_test( value, - scope_value=scope_value, - scope=scope, - allowed_sources={"action-addset"}, - context_label=f"SQL field '{field_name}'", + field_name, + 'SQL', + scope_value, + scope, + ACTION_ADDITIONAL_SECRET_SOURCES, ) @@ -857,6 +892,10 @@ def _load_existing_plugin_for_test(plugin_context, user_id): return get_group_action(active_group, plugin_identifier, return_type=SecretReturnType.NAME) if plugin_scope == 'global': + # Global actions are admin-managed. Gate here so every test route inherits the check, + # including routes that do not otherwise resolve an action identity scope. + if "Admin" not in session.get("user", {}).get("roles", []): + raise PermissionError("Admin role required to load a global action for testing.") return get_global_action(plugin_identifier, return_type=SecretReturnType.NAME) return get_personal_action(user_id, plugin_identifier, return_type=SecretReturnType.NAME) @@ -1936,6 +1975,7 @@ def discover_mcp_tools(): try: existing_plugin = _load_existing_plugin_for_test(payload.get('plugin_context'), user_id) scope_type, scope_id = _resolve_action_identity_context(payload, existing_plugin, user_id) + plugin_scope_value, plugin_scope = _resolve_plugin_secret_context(existing_plugin, user_id) discovery_manifest = dict(payload) discovery_manifest['mcp_operation_id'] = mcp_operation_id @@ -1988,10 +2028,17 @@ def discover_mcp_tools(): else: auth = discovery_manifest.get('auth') if isinstance(discovery_manifest.get('auth'), dict) else {} if auth.get('key'): - auth['key'] = _resolve_secret_value_for_plugin_test(auth.get('key'), 'auth.key', plugin_label='MCP') + auth['key'] = _resolve_secret_value_for_action_test( + auth.get('key'), + 'auth.key', + 'MCP', + plugin_scope_value, + plugin_scope, + ACTION_AUTH_SECRET_SOURCES, + ) discovery_manifest['auth'] = auth - _hydrate_mcp_custom_headers_for_test(discovery_manifest, existing_plugin) + _hydrate_mcp_custom_headers_for_test(discovery_manifest, existing_plugin, plugin_scope_value, plugin_scope) is_valid, validation_errors = PluginHealthChecker.validate_plugin_manifest(discovery_manifest, MCP_PLUGIN_TYPE) if not is_valid: @@ -2456,7 +2503,15 @@ def test_cosmos_connection(): return jsonify({'success': False, 'error': 'Stored Cosmos DB account key could not be resolved for testing. Re-enter the account key.'}), 400 try: - auth_key = _resolve_secret_value_for_plugin_test(auth_key, 'auth.key', plugin_label='Cosmos DB') + plugin_scope_value, plugin_scope = _resolve_plugin_secret_context(existing_plugin, user_id) + auth_key = _resolve_secret_value_for_action_test( + auth_key, + 'auth.key', + 'Cosmos DB', + plugin_scope_value, + plugin_scope, + ACTION_AUTH_SECRET_SOURCES, + ) except ValueError as exc: return jsonify({'success': False, 'error': str(exc)}), 400 @@ -2611,7 +2666,15 @@ def test_yamcs_connection(): }), 400 try: - auth_key = _resolve_secret_value_for_plugin_test(auth_key, 'auth.key', plugin_label='Yamcs') + plugin_scope_value, plugin_scope = _resolve_plugin_secret_context(existing_plugin, user_id) + auth_key = _resolve_secret_value_for_action_test( + auth_key, + 'auth.key', + 'Yamcs', + plugin_scope_value, + plugin_scope, + ACTION_AUTH_SECRET_SOURCES, + ) except ValueError as exc: return jsonify({'success': False, 'error': str(exc)}), 400 @@ -2779,7 +2842,15 @@ def test_rocksdb_connection(): }), 400 try: - auth_key = _resolve_secret_value_for_plugin_test(auth_key, 'auth.key', plugin_label='RocksDB') + plugin_scope_value, plugin_scope = _resolve_plugin_secret_context(existing_plugin, user_id) + auth_key = _resolve_secret_value_for_action_test( + auth_key, + 'auth.key', + 'RocksDB', + plugin_scope_value, + plugin_scope, + ACTION_AUTH_SECRET_SOURCES, + ) except ValueError: return jsonify({ 'success': False, @@ -2868,3 +2939,269 @@ def test_rocksdb_connection(): 'success': False, 'error': 'The RocksDB service could not be reached. Verify the base URL and that the service is running.' }), 400 + + +def _rehydrate_action_test_secret(current_value, stored_value, plugin_label, field_label): + """Restore a masked action secret from the stored manifest for a transient test.""" + resolved_value = current_value + if resolved_value in ('', None, ui_trigger_word) and stored_value: + resolved_value = stored_value + if resolved_value == ui_trigger_word: + raise ValueError( + f"The stored {plugin_label} value for '{field_label}' could not be resolved for testing. Re-enter the credential." + ) + return resolved_value + + +def _prepare_action_test_manifest(data, plugin_type, plugin_label): + """Build a hydrated, validated transient manifest for an action test-connection route.""" + if not isinstance(data, dict): + raise ValueError('Invalid action connection test payload.') + + user_id = get_current_user_id() + existing_plugin = _load_existing_plugin_for_test(data.get('plugin_context'), user_id) + scope_type, scope_id = _resolve_action_identity_context(data, existing_plugin, user_id) + # Secret references are resolved against the loaded action's own Key Vault scope, never + # against a scope derived from the request body. + plugin_scope_value, plugin_scope = _resolve_plugin_secret_context(existing_plugin, user_id) + + default_name = f'{plugin_type}_connection_test' + manifest = { + 'name': str(data.get('name') or '').strip() or default_name, + 'displayName': str(data.get('displayName') or '').strip() or f'{plugin_label} connection test', + 'description': str(data.get('description') or '').strip() or f'Transient {plugin_label} connection test manifest', + 'type': plugin_type, + 'endpoint': str(data.get('endpoint') or '').strip(), + 'auth': dict(data.get('auth')) if isinstance(data.get('auth'), dict) else {}, + 'metadata': dict(data.get('metadata')) if isinstance(data.get('metadata'), dict) else {}, + 'additionalFields': dict(data.get('additionalFields')) if isinstance(data.get('additionalFields'), dict) else {}, + } + + identity_id = str(data.get('identity_id') or '').strip() + if identity_id: + manifest['identity_id'] = identity_id + + _apply_plugin_runtime_defaults(manifest) + + existing_auth = {} + existing_additional_fields = {} + if isinstance(existing_plugin, dict): + if isinstance(existing_plugin.get('auth'), dict): + existing_auth = existing_plugin['auth'] + if isinstance(existing_plugin.get('additionalFields'), dict): + existing_additional_fields = existing_plugin['additionalFields'] + + auth = manifest.get('auth') if isinstance(manifest.get('auth'), dict) else {} + for auth_field in ACTION_CONNECTION_TEST_AUTH_SECRET_FIELDS: + # Only rehydrate fields the modal actually collected, so switching an action to a + # different authentication method never resurrects a credential the user removed. + if auth_field not in auth: + continue + resolved_value = _rehydrate_action_test_secret( + auth.get(auth_field), + existing_auth.get(auth_field), + plugin_label, + f'auth.{auth_field}', + ) + if resolved_value not in ('', None): + auth[auth_field] = resolved_value + manifest['auth'] = auth + + additional_fields = manifest.get('additionalFields') if isinstance(manifest.get('additionalFields'), dict) else {} + for additional_field in ACTION_CONNECTION_TEST_ADDITIONAL_SECRET_FIELDS: + if additional_field not in additional_fields: + continue + resolved_value = _rehydrate_action_test_secret( + additional_fields.get(additional_field), + existing_additional_fields.get(additional_field), + plugin_label, + f'additionalFields.{additional_field}', + ) + if resolved_value not in ('', None): + additional_fields[additional_field] = resolved_value + + if plugin_type == 'openapi' and not additional_fields.get('openapi_spec_content'): + stored_spec_content = existing_additional_fields.get('openapi_spec_content') + if stored_spec_content: + additional_fields['openapi_spec_content'] = stored_spec_content + additional_fields.setdefault( + 'openapi_source_type', + existing_additional_fields.get('openapi_source_type') or 'content', + ) + manifest['additionalFields'] = additional_fields + + if plugin_type == MCP_PLUGIN_TYPE: + _hydrate_mcp_custom_headers_for_test(manifest, existing_plugin, plugin_scope_value, plugin_scope) + + if manifest.get('identity_id'): + manifest = hydrate_action_identity_reference( + manifest, + scope_type, + scope_id, + return_type=SecretReturnType.VALUE, + ) + else: + auth = manifest.get('auth') if isinstance(manifest.get('auth'), dict) else {} + if auth.get('key'): + auth['key'] = _resolve_secret_value_for_action_test( + auth.get('key'), + 'auth.key', + plugin_label, + plugin_scope_value, + plugin_scope, + ACTION_AUTH_SECRET_SOURCES, + ) + manifest['auth'] = auth + additional_fields = manifest.get('additionalFields') if isinstance(manifest.get('additionalFields'), dict) else {} + if additional_fields.get('private_key_passphrase'): + additional_fields['private_key_passphrase'] = _resolve_secret_value_for_action_test( + additional_fields.get('private_key_passphrase'), + 'additionalFields.private_key_passphrase', + plugin_label, + plugin_scope_value, + plugin_scope, + ACTION_ADDITIONAL_SECRET_SOURCES, + ) + manifest['additionalFields'] = additional_fields + + is_valid, validation_errors = PluginHealthChecker.validate_plugin_manifest(manifest, plugin_type) + if not is_valid: + raise ValueError('; '.join(validation_errors) or f'The {plugin_label} action configuration is invalid.') + + return manifest, scope_type, scope_id + + +def _run_action_connection_test(plugin_type, plugin_label, tester, before_test=None): + """Prepare a transient manifest, run one action connection test, and jsonify the result.""" + data = request.get_json(silent=True) or {} + + try: + manifest, scope_type, scope_id = _prepare_action_test_manifest(data, plugin_type, plugin_label) + if callable(before_test): + before_test(manifest, scope_type, scope_id) + except PermissionError as exc: + return jsonify({'success': False, 'error': str(exc)}), 403 + except LookupError as exc: + return jsonify({'success': False, 'error': str(exc)}), 404 + except ValueError as exc: + return jsonify({'success': False, 'error': str(exc)}), 400 + except McpRuntimeError as exc: + return jsonify({'success': False, 'error': str(exc)}), get_mcp_error_http_status(exc) + + try: + result = tester(manifest) + except Exception as exc: + log_event( + f'[ACTION_TEST] {plugin_label} connection test raised an unexpected error: {exc}', + extra={'plugin_type': plugin_type}, + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({ + 'success': False, + 'error': f'The {plugin_label} connection test failed unexpectedly. Check the action configuration and try again.', + }), 500 + + status_code = int(result.get('status') or (200 if result.get('success') else 400)) + response_payload = {'success': bool(result.get('success'))} + if result.get('success'): + response_payload['message'] = result.get('message') or 'Connection successful.' + else: + response_payload['error'] = result.get('error') or 'Connection failed.' + if result.get('details'): + response_payload['details'] = result['details'] + if result.get('warnings'): + response_payload['warnings'] = result['warnings'] + + return jsonify(response_payload), status_code + + +@bpap.route('/api/plugins/test-openapi-connection', methods=['POST']) +@swagger_route(security=get_auth_security()) +@login_required +@user_required +def test_openapi_action_connection(): + """Test an OpenAPI action by parsing its specification and probing the authenticated base URL.""" + return _run_action_connection_test('openapi', 'OpenAPI', test_openapi_connection) + + +@bpap.route('/api/plugins/test-azure-maps-connection', methods=['POST']) +@swagger_route(security=get_auth_security()) +@login_required +@user_required +def test_azure_maps_action_connection(): + """Test an Azure Maps action by retrieving a single base road tile.""" + return _run_action_connection_test(AZURE_MAPS_PLUGIN_TYPE, 'Azure Maps', test_azure_maps_connection) + + +@bpap.route('/api/plugins/test-blob-storage-connection', methods=['POST']) +@swagger_route(security=get_auth_security()) +@login_required +@user_required +def test_blob_storage_action_connection(): + """Test a Blob Storage action by reading container properties and listing one blob.""" + return _run_action_connection_test(BLOB_STORAGE_PLUGIN_TYPE, 'Blob Storage', test_blob_storage_connection) + + +@bpap.route('/api/plugins/test-databricks-connection', methods=['POST']) +@swagger_route(security=get_auth_security()) +@login_required +@user_required +def test_databricks_action_connection(): + """Test a Databricks action by reading the configured SQL warehouse.""" + return _run_action_connection_test(DATABRICKS_PLUGIN_TYPE, 'Databricks', test_databricks_connection) + + +@bpap.route('/api/plugins/test-log-analytics-connection', methods=['POST']) +@swagger_route(security=get_auth_security()) +@login_required +@user_required +def test_log_analytics_action_connection(): + """Test a Log Analytics action by running a trivial KQL query against the workspace.""" + return _run_action_connection_test('log_analytics', 'Log Analytics', test_log_analytics_connection) + + +@bpap.route('/api/plugins/test-mcp-connection', methods=['POST']) +@swagger_route(security=get_auth_security()) +@login_required +@user_required +def test_mcp_action_connection(): + """Test an MCP action by initializing a session and listing the server's tools.""" + + def enforce_mcp_test_policy(manifest, scope_type, scope_id): + if scope_type != WORKSPACE_IDENTITY_SCOPE_GLOBAL: + stdio_error = _reject_non_admin_mcp_stdio(manifest, scope_label='non-global') + if stdio_error: + raise PermissionError(stdio_error) + _enforce_mcp_destination_policy( + manifest, + scope_type, + scope_id, + operation='mcp_connection_test', + user_id=get_current_user_id(), + ) + + return _run_action_connection_test( + MCP_PLUGIN_TYPE, + 'MCP', + test_mcp_connection, + before_test=enforce_mcp_test_policy, + ) + + +@bpap.route('/api/plugins/test-snowflake-connection', methods=['POST']) +@swagger_route(security=get_auth_security()) +@login_required +@user_required +def test_snowflake_action_connection(): + """Test a Snowflake action by opening a session and reading the account version.""" + return _run_action_connection_test(SNOWFLAKE_PLUGIN_TYPE, 'Snowflake', test_snowflake_connection) + + +@bpap.route('/api/plugins/test-tableau-connection', methods=['POST']) +@swagger_route(security=get_auth_security()) +@login_required +@user_required +def test_tableau_action_connection(): + """Test a Tableau action by signing in to the configured server and site.""" + return _run_action_connection_test(TABLEAU_PLUGIN_TYPE, 'Tableau', test_tableau_connection) diff --git a/application/single_app/static/js/plugin_modal_stepper.js b/application/single_app/static/js/plugin_modal_stepper.js index 6fd0a7b99..66b213ff5 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 LOG_ANALYTICS_ACTION_IDENTITY_AUTH_TYPES = ['client_secret', 'managed_identity']; const BLOB_STORAGE_PLUGIN_TYPE = 'blob_storage'; const DATABRICKS_PLUGIN_TYPE = 'databricks'; const DATABRICKS_DEFAULT_CLOUD = 'azure_commercial'; @@ -62,6 +63,19 @@ const ROCKSDB_DEFAULT_COLUMN_FAMILY = 'default'; const ROCKSDB_DEFAULT_MAX_RESULTS = 100; const ROCKSDB_DEFAULT_MAX_VALUE_BYTES = 32768; const ROCKSDB_DEFAULT_TIMEOUT = 30; +const LOG_ANALYTICS_PLUGIN_TYPE = 'log_analytics'; +const LOG_ANALYTICS_DEFAULT_ENDPOINT = 'https://api.loganalytics.io'; +const LOG_ANALYTICS_DEFAULT_CLOUD = 'public'; +const ACTION_CONNECTION_TEST_CONFIG = { + openapi: { idPrefix: 'openapi', url: '/api/plugins/test-openapi-connection', label: 'OpenAPI' }, + azureMaps: { idPrefix: 'azure-maps', url: '/api/plugins/test-azure-maps-connection', label: 'Azure Maps' }, + blobStorage: { idPrefix: 'blob-storage', url: '/api/plugins/test-blob-storage-connection', label: 'Blob Storage' }, + databricks: { idPrefix: 'databricks', url: '/api/plugins/test-databricks-connection', label: 'Databricks' }, + logAnalytics: { idPrefix: 'log-analytics', url: '/api/plugins/test-log-analytics-connection', label: 'Log Analytics' }, + mcp: { idPrefix: 'mcp', url: '/api/plugins/test-mcp-connection', label: 'MCP' }, + snowflake: { idPrefix: 'snowflake', url: '/api/plugins/test-snowflake-connection', label: 'Snowflake' }, + tableau: { idPrefix: 'tableau', url: '/api/plugins/test-tableau-connection', label: 'Tableau' } +}; const CHART_DEFAULT_ENDPOINT = 'chart://internal'; const INTERNAL_DOCUMENT_SEARCH_ENDPOINT = 'internal://document-search'; const MSGRAPH_DEFAULT_ENDPOINT = 'https://graph.microsoft.com'; @@ -524,6 +538,9 @@ export class PluginModalStepper { if (kind === 'yamcs') { return this.actionIdentities.filter(identity => YAMCS_ACTION_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))); + } return this.actionIdentities; } @@ -534,6 +551,7 @@ 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('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'); } @@ -597,6 +615,7 @@ export class PluginModalStepper { 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' }; @@ -615,6 +634,7 @@ export class PluginModalStepper { 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' }; @@ -655,7 +675,8 @@ export class PluginModalStepper { databricks: 'databricks-auth-method', snowflake: 'snowflake-auth-method', tableau: 'tableau-auth-method', - yamcs: 'yamcs-auth-method' + yamcs: 'yamcs-auth-method', + logAnalytics: 'log-analytics-auth-method' }; const authSelect = document.getElementById(authSelectIds[kind] || 'plugin-auth-type-generic'); if (authSelect) { @@ -673,6 +694,8 @@ export class PluginModalStepper { this.toggleTableauAuthFields(); } else if (kind === 'yamcs') { this.toggleYamcsAuthFields(); + } else if (kind === 'logAnalytics') { + this.toggleLogAnalyticsAuthFields(); } else { this.toggleGenericAuthFields(); } @@ -705,6 +728,18 @@ 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')); + const logAnalyticsCloud = document.getElementById('log-analytics-cloud'); + if (logAnalyticsCloud) { + logAnalyticsCloud.addEventListener('change', () => this.handleLogAnalyticsCloudChange()); + } + const logAnalyticsAuthMethod = document.getElementById('log-analytics-auth-method'); + if (logAnalyticsAuthMethod) { + logAnalyticsAuthMethod.addEventListener('change', () => this.toggleLogAnalyticsAuthFields()); + } + const logAnalyticsIdentitySelect = document.getElementById('log-analytics-identity-select'); + if (logAnalyticsIdentitySelect) { + logAnalyticsIdentitySelect.addEventListener('change', () => this.handleActionIdentityChange('logAnalytics')); + } document.getElementById('plugin-auth-identity-select-generic').addEventListener('change', () => this.handleActionIdentityChange('generic')); const msGraphMailSendMode = document.getElementById('msgraph-mail-send-mode'); if (msGraphMailSendMode) { @@ -761,6 +796,14 @@ export class PluginModalStepper { testYamcsBtn.addEventListener('click', () => this.testYamcsConnection()); } + // Test connection buttons for the remaining configurable action types + Object.keys(ACTION_CONNECTION_TEST_CONFIG).forEach(testKey => { + const button = document.getElementById(`${ACTION_CONNECTION_TEST_CONFIG[testKey].idPrefix}-test-connection-btn`); + if (button) { + button.addEventListener('click', () => this.runActionConnectionTest(testKey)); + } + }); + const keyVaultReminderToggle = document.getElementById('plugin-key-vault-reminder-enabled'); if (keyVaultReminderToggle) { keyVaultReminderToggle.addEventListener('change', () => this.toggleKeyVaultReminderFields()); @@ -1331,6 +1374,10 @@ export class PluginModalStepper { return !!(type && type.toLowerCase() === AZURE_MAPS_PLUGIN_TYPE); } + isLogAnalyticsType(type = this.selectedType) { + return !!(type && type.toLowerCase() === LOG_ANALYTICS_PLUGIN_TYPE); + } + isChartType(type = this.selectedType) { return !!(type && type.toLowerCase() === 'chart'); } @@ -3437,7 +3484,7 @@ export class PluginModalStepper { } isStructuredConfigType(type = this.selectedType) { - return this.isSqlType(type) || this.isCosmosType(type) || this.isRocksDbType(type) || this.isDocumentSearchType(type) || this.isBlobStorageType(type) || this.isDatabricksType(type) || this.isSnowflakeType(type) || this.isTableauType(type) || this.isYamcsType(type) || this.isMcpType(type) || this.isSimpleChatType(type) || this.isMsGraphType(type) || this.isAzureMapsType(type) || this.isChartType(type); + return this.isSqlType(type) || this.isCosmosType(type) || this.isRocksDbType(type) || this.isDocumentSearchType(type) || this.isBlobStorageType(type) || this.isDatabricksType(type) || this.isSnowflakeType(type) || this.isTableauType(type) || this.isYamcsType(type) || this.isMcpType(type) || this.isSimpleChatType(type) || this.isMsGraphType(type) || this.isAzureMapsType(type) || this.isChartType(type) || this.isLogAnalyticsType(type); } showConfigSectionForType() { @@ -3457,6 +3504,7 @@ export class PluginModalStepper { msGraph: document.getElementById('msgraph-config-section'), mcp: document.getElementById('mcp-config-section'), azureMaps: document.getElementById('azure-maps-config-section'), + logAnalytics: document.getElementById('log-analytics-config-section'), chart: document.getElementById('chart-config-section') }; @@ -3511,6 +3559,9 @@ export class PluginModalStepper { this.updateMsGraphCalendarDelayVisibility(); } else if (this.isAzureMapsType()) { showOnly('azureMaps'); + } else if (this.isLogAnalyticsType()) { + showOnly('logAnalytics'); + this.initializeLogAnalyticsConfiguration(); } else if (this.isChartType()) { showOnly('chart'); this.renderChartConfiguration(); @@ -3579,6 +3630,8 @@ export class PluginModalStepper { titleEl.textContent = 'Microsoft Graph Configuration'; } else if (isAzureMapsType) { titleEl.textContent = 'Azure Maps Configuration'; + } else if (this.isLogAnalyticsType()) { + titleEl.textContent = 'Log Analytics Configuration'; } else if (isChartType) { titleEl.textContent = 'Chart Configuration'; } else { @@ -3768,6 +3821,7 @@ export class PluginModalStepper { const msGraphSection = document.getElementById('msgraph-config-section'); const azureMapsSection = document.getElementById('azure-maps-config-section'); const chartSection = document.getElementById('chart-config-section'); + const logAnalyticsSection = document.getElementById('log-analytics-config-section'); const isOpenApiVisible = !openApiSection.classList.contains('d-none'); const isSqlVisible = !sqlSection.classList.contains('d-none'); const isCosmosVisible = !cosmosSection.classList.contains('d-none'); @@ -3783,6 +3837,7 @@ export class PluginModalStepper { const isMsGraphVisible = !msGraphSection.classList.contains('d-none'); const isAzureMapsVisible = !azureMapsSection.classList.contains('d-none'); const isChartVisible = !chartSection.classList.contains('d-none'); + const isLogAnalyticsVisible = !!logAnalyticsSection && !logAnalyticsSection.classList.contains('d-none'); if (isOpenApiVisible) { // Validate OpenAPI fields @@ -4283,6 +4338,38 @@ export class PluginModalStepper { this.showError('Azure Maps subscription key is required.'); return false; } + } else if (isLogAnalyticsVisible) { + const workspaceId = document.getElementById('log-analytics-workspace-id').value.trim(); + if (!workspaceId) { + this.showError('Log Analytics workspace ID is required.'); + return false; + } + + const cloud = document.getElementById('log-analytics-cloud').value; + if (cloud === 'custom') { + const authorityHost = document.getElementById('log-analytics-authority-host').value.trim(); + const endpointOverride = document.getElementById('log-analytics-endpoint-override').value.trim(); + if (!authorityHost || !endpointOverride) { + this.showError('Authority host and endpoint override are required for the custom cloud.'); + return false; + } + } + + const selectedLogAnalyticsIdentity = this.getSelectedActionIdentity('logAnalytics'); + const logAnalyticsAuthMethod = document.getElementById('log-analytics-auth-method').value; + if (!selectedLogAnalyticsIdentity && logAnalyticsAuthMethod === 'servicePrincipal') { + const clientId = document.getElementById('log-analytics-auth-identity').value.trim(); + const clientSecret = document.getElementById('log-analytics-auth-key').value.trim(); + const tenantId = document.getElementById('log-analytics-auth-tenant-id').value.trim(); + if (!clientId || !clientSecret || !tenantId) { + this.showError('Client ID, client secret, and tenant ID are required for service principal authentication.'); + return false; + } + } + if (!selectedLogAnalyticsIdentity && logAnalyticsAuthMethod === 'key' && !document.getElementById('log-analytics-auth-key').value.trim()) { + this.showError('A key is required for key authentication.'); + return false; + } } else if (isChartVisible) { const capabilityValues = Object.values(this.getSelectedChartCapabilities()); if (!capabilityValues.some(Boolean)) { @@ -5095,6 +5182,506 @@ export class PluginModalStepper { } } + getOpenApiAuthConfiguration() { + const selectedIdentity = this.getSelectedActionIdentity('openapi'); + const authType = document.getElementById('plugin-auth-type')?.value || 'none'; + const auth = {}; + const additionalFields = {}; + let identityId = ''; + + if (selectedIdentity) { + identityId = selectedIdentity.id || selectedIdentity.identity_id || ''; + auth.type = 'identity'; + auth.identity = identityId; + additionalFields.identity_auth_type = this.getIdentityAuthType(selectedIdentity); + additionalFields.auth_method = 'identity'; + } else if (authType === 'api_key') { + auth.type = 'key'; + auth.key = document.getElementById('plugin-auth-api-key-value')?.value.trim() || ''; + additionalFields.api_key_location = document.getElementById('plugin-auth-api-key-location')?.value || 'header'; + additionalFields.api_key_name = document.getElementById('plugin-auth-api-key-name')?.value.trim() || ''; + } else if (authType === 'bearer') { + auth.type = 'key'; + auth.key = document.getElementById('plugin-auth-bearer-token')?.value.trim() || ''; + additionalFields.auth_method = 'bearer'; + } else if (authType === 'basic') { + auth.type = 'key'; + const username = document.getElementById('plugin-auth-basic-username')?.value.trim() || ''; + const password = document.getElementById('plugin-auth-basic-password')?.value.trim() || ''; + auth.key = `${username}:${password}`; + additionalFields.auth_method = 'basic'; + } else if (authType === 'oauth2') { + auth.type = 'key'; + auth.key = document.getElementById('plugin-auth-oauth2-token')?.value.trim() || ''; + additionalFields.auth_method = 'oauth2'; + } else if (authType === 'none') { + auth.type = 'key'; + auth.key = ''; + additionalFields.auth_method = 'none'; + } + + return { auth, additionalFields, identityId }; + } + + getAzureMapsConfiguration() { + return { + endpoint: AZURE_MAPS_DEFAULT_ENDPOINT, + auth: { + type: 'key', + key: document.getElementById('azure-maps-key')?.value.trim() || '' + }, + additionalFields: {}, + identityId: '' + }; + } + + getBlobStorageConfiguration() { + const connectionString = document.getElementById('blob-storage-connection-string')?.value.trim() || ''; + const containerName = document.getElementById('blob-storage-container-name')?.value.trim() || ''; + const blobPrefix = this.normalizeBlobStoragePrefix(document.getElementById('blob-storage-blob-prefix')?.value || ''); + + const additionalFields = { + container_name: containerName, + blob_storage_capabilities: this.getSelectedBlobStorageCapabilities(), + blob_storage_read_file_types: this.getSelectedBlobStorageReadFileTypes(), + blob_storage_upload_file_types: this.getSelectedBlobStorageUploadFileTypes() + }; + if (blobPrefix) { + additionalFields.blob_prefix = blobPrefix; + } + + return { + endpoint: this.deriveBlobStorageEndpointFromConnectionString(connectionString) || this.originalPlugin?.endpoint || '', + auth: { type: 'connection_string', key: connectionString }, + additionalFields, + identityId: '' + }; + } + + getLogAnalyticsIdentityAuthMethod(identity) { + return this.getIdentityAuthType(identity) === 'client_secret' ? 'servicePrincipal' : 'identity'; + } + + initializeLogAnalyticsConfiguration() { + const cloudField = document.getElementById('log-analytics-cloud'); + if (cloudField && !cloudField.value) { + cloudField.value = LOG_ANALYTICS_DEFAULT_CLOUD; + } + const endpointField = document.getElementById('log-analytics-endpoint'); + if (endpointField) { + endpointField.placeholder = LOG_ANALYTICS_DEFAULT_ENDPOINT; + } + + this.clearActionConnectionTestResult('logAnalytics'); + this.handleLogAnalyticsCloudChange(); + this.toggleLogAnalyticsAuthFields(); + } + + handleLogAnalyticsCloudChange() { + const cloud = document.getElementById('log-analytics-cloud')?.value || LOG_ANALYTICS_DEFAULT_CLOUD; + const customGroup = document.getElementById('log-analytics-custom-cloud-group'); + if (customGroup) { + customGroup.classList.toggle('d-none', cloud !== 'custom'); + } + } + + toggleLogAnalyticsAuthFields() { + const selectedIdentity = this.getSelectedActionIdentity('logAnalytics'); + const authMethod = selectedIdentity + ? this.getLogAnalyticsIdentityAuthMethod(selectedIdentity) + : (document.getElementById('log-analytics-auth-method')?.value || 'identity'); + + const identityGroup = document.getElementById('log-analytics-auth-identity-group'); + const keyGroup = document.getElementById('log-analytics-auth-key-group'); + const tenantGroup = document.getElementById('log-analytics-auth-tenant-id-group'); + const identityLabel = document.getElementById('log-analytics-auth-identity-label'); + const keyLabel = document.getElementById('log-analytics-auth-key-label'); + + const showIdentity = !selectedIdentity && ['identity', 'servicePrincipal'].includes(authMethod); + const showKey = !selectedIdentity && ['servicePrincipal', 'key'].includes(authMethod); + const showTenant = !selectedIdentity && authMethod === 'servicePrincipal'; + + if (identityGroup) identityGroup.classList.toggle('d-none', !showIdentity); + if (keyGroup) keyGroup.classList.toggle('d-none', !showKey); + if (tenantGroup) tenantGroup.classList.toggle('d-none', !showTenant); + + if (identityLabel) { + identityLabel.textContent = authMethod === 'servicePrincipal' ? 'Client ID' : 'Managed Identity Client ID'; + } + if (keyLabel) { + keyLabel.textContent = authMethod === 'servicePrincipal' ? 'Client Secret' : 'Key'; + } + + this.updateLogAnalyticsAuthInfo(authMethod, selectedIdentity); + } + + updateLogAnalyticsAuthInfo(authMethod, selectedIdentity) { + const infoDiv = document.getElementById('log-analytics-auth-info'); + const infoText = document.getElementById('log-analytics-auth-info-text'); + if (!infoDiv || !infoText) { + return; + } + + let message = 'Managed Identity uses the application identity. Grant it the Log Analytics Reader role on the target workspace.'; + if (selectedIdentity) { + message = 'Credentials come from the selected reusable identity. Action-specific fields are ignored.'; + } else if (authMethod === 'servicePrincipal') { + message = 'Service Principal authentication needs a client ID, client secret, and tenant ID with Log Analytics Reader access to the workspace.'; + } else if (authMethod === 'user') { + message = 'On-Behalf-Of User authentication runs queries as the signed-in user, so results respect that user\'s workspace permissions.'; + } else if (authMethod === 'key') { + message = 'Key authentication is only supported by custom Log Analytics-compatible endpoints.'; + } + + infoText.textContent = message; + infoDiv.classList.remove('d-none'); + } + + getLogAnalyticsConfiguration() { + const selectedIdentity = this.getSelectedActionIdentity('logAnalytics'); + const authMethod = selectedIdentity + ? this.getLogAnalyticsIdentityAuthMethod(selectedIdentity) + : (document.getElementById('log-analytics-auth-method')?.value || 'identity'); + const cloud = document.getElementById('log-analytics-cloud')?.value || LOG_ANALYTICS_DEFAULT_CLOUD; + + // Preserve stored fields such as query_history that Step 3 does not surface. + const storedFields = this.originalPlugin?.additionalFields; + const additionalFields = (storedFields && typeof storedFields === 'object' && !Array.isArray(storedFields)) + ? { ...storedFields } + : {}; + delete additionalFields.authorityHost; + delete additionalFields.endpointOverride; + delete additionalFields.identity_auth_type; + + additionalFields.workspaceId = document.getElementById('log-analytics-workspace-id')?.value.trim() || ''; + additionalFields.cloud = cloud; + if (!Array.isArray(additionalFields.query_history)) { + additionalFields.query_history = []; + } + if (cloud === 'custom') { + additionalFields.authorityHost = document.getElementById('log-analytics-authority-host')?.value.trim() || ''; + additionalFields.endpointOverride = document.getElementById('log-analytics-endpoint-override')?.value.trim() || ''; + } + + const auth = {}; + let identityId = ''; + + if (selectedIdentity) { + identityId = selectedIdentity.id || selectedIdentity.identity_id || ''; + auth.type = 'identity'; + auth.identity = identityId; + additionalFields.identity_auth_type = this.getIdentityAuthType(selectedIdentity); + } else if (authMethod === 'servicePrincipal') { + auth.type = 'servicePrincipal'; + auth.identity = document.getElementById('log-analytics-auth-identity')?.value.trim() || ''; + auth.key = document.getElementById('log-analytics-auth-key')?.value.trim() || ''; + auth.tenantId = document.getElementById('log-analytics-auth-tenant-id')?.value.trim() || ''; + } else if (authMethod === 'key') { + auth.type = 'key'; + auth.key = document.getElementById('log-analytics-auth-key')?.value.trim() || ''; + } else if (authMethod === 'user') { + auth.type = 'user'; + } else { + auth.type = 'identity'; + const managedIdentityClientId = document.getElementById('log-analytics-auth-identity')?.value.trim() || ''; + if (managedIdentityClientId) { + auth.identity = managedIdentityClientId; + } + } + + const endpoint = document.getElementById('log-analytics-endpoint')?.value.trim() + || (cloud === 'custom' ? additionalFields.endpointOverride : '') + || LOG_ANALYTICS_DEFAULT_ENDPOINT; + + return { endpoint, auth, additionalFields, identityId }; + } + + populateLogAnalyticsConfiguration(plugin) { + const additionalFields = (plugin?.additionalFields && typeof plugin.additionalFields === 'object') + ? plugin.additionalFields + : {}; + const auth = (plugin?.auth && typeof plugin.auth === 'object') ? plugin.auth : {}; + + const workspaceField = document.getElementById('log-analytics-workspace-id'); + if (workspaceField) { + workspaceField.value = additionalFields.workspaceId || ''; + } + const cloudField = document.getElementById('log-analytics-cloud'); + if (cloudField) { + cloudField.value = additionalFields.cloud || LOG_ANALYTICS_DEFAULT_CLOUD; + } + const endpointField = document.getElementById('log-analytics-endpoint'); + if (endpointField) { + endpointField.value = plugin?.endpoint && plugin.endpoint !== LOG_ANALYTICS_DEFAULT_ENDPOINT ? plugin.endpoint : ''; + } + const authorityHostField = document.getElementById('log-analytics-authority-host'); + if (authorityHostField) { + authorityHostField.value = additionalFields.authorityHost || ''; + } + const endpointOverrideField = document.getElementById('log-analytics-endpoint-override'); + if (endpointOverrideField) { + endpointOverrideField.value = additionalFields.endpointOverride || ''; + } + + const authType = String(auth.type || 'identity'); + const authMethodField = document.getElementById('log-analytics-auth-method'); + if (authMethodField && !plugin?.identity_id) { + authMethodField.value = ['identity', 'user', 'servicePrincipal', 'key'].includes(authType) ? authType : 'identity'; + } + + const authIdentityField = document.getElementById('log-analytics-auth-identity'); + if (authIdentityField) { + authIdentityField.value = auth.identity && auth.identity !== 'managed_identity' ? auth.identity : ''; + } + const authKeyField = document.getElementById('log-analytics-auth-key'); + if (authKeyField) { + authKeyField.value = auth.key || ''; + } + const authTenantField = document.getElementById('log-analytics-auth-tenant-id'); + if (authTenantField) { + authTenantField.value = auth.tenantId || ''; + } + + if (plugin?.identity_id) { + this.setSelectedActionIdentity('logAnalytics', plugin.identity_id); + } + + this.handleLogAnalyticsCloudChange(); + this.toggleLogAnalyticsAuthFields(); + } + + getActionConnectionTestElements(testKey) { + const config = ACTION_CONNECTION_TEST_CONFIG[testKey]; + if (!config) { + return null; + } + return { + config, + button: document.getElementById(`${config.idPrefix}-test-connection-btn`), + result: document.getElementById(`${config.idPrefix}-test-connection-result`), + alert: document.getElementById(`${config.idPrefix}-test-connection-alert`) + }; + } + + clearActionConnectionTestResult(testKey) { + const elements = this.getActionConnectionTestElements(testKey); + if (!elements) { + return; + } + if (elements.result) { + elements.result.classList.add('d-none'); + } + if (elements.alert) { + elements.alert.replaceChildren(); + } + } + + showActionConnectionTestMessage(testKey, variant, message, iconClass = '') { + const elements = this.getActionConnectionTestElements(testKey); + if (!elements || !elements.result || !elements.alert) { + return; + } + + elements.alert.className = `alert alert-${variant} mb-0 py-2 px-3 small`; + elements.alert.replaceChildren(); + if (iconClass) { + const icon = document.createElement('i'); + icon.className = `${iconClass} me-2`; + elements.alert.appendChild(icon); + } + elements.alert.appendChild(document.createTextNode(message)); + elements.result.classList.remove('d-none'); + } + + buildActionConnectionTestConfig(testKey) { + if (testKey === 'openapi') { + const endpoint = document.getElementById('plugin-endpoint')?.value.trim() || ''; + if (!endpoint) { + throw new Error('Enter the API base URL before testing the connection.'); + } + + const openApiAuthConfig = this.getOpenApiAuthConfiguration(); + const additionalFields = { ...openApiAuthConfig.additionalFields, base_url: endpoint }; + const specContent = document.getElementById('plugin-openapi-file')?.dataset.specContent || ''; + if (specContent) { + try { + additionalFields.openapi_spec_content = JSON.parse(specContent); + additionalFields.openapi_source_type = 'content'; + } catch (error) { + throw new Error('The uploaded OpenAPI specification could not be parsed.'); + } + } else if (!this.isEditMode) { + throw new Error('Upload an OpenAPI specification file before testing the connection.'); + } + + return { + endpoint, + auth: openApiAuthConfig.auth, + additionalFields, + identityId: openApiAuthConfig.identityId + }; + } + + if (testKey === 'azureMaps') { + const azureMapsConfig = this.getAzureMapsConfiguration(); + if (!azureMapsConfig.auth.key && !this.isEditMode) { + throw new Error('Enter the Azure Maps subscription key before testing the connection.'); + } + return azureMapsConfig; + } + + if (testKey === 'blobStorage') { + const blobStorageConfig = this.getBlobStorageConfiguration(); + if (!blobStorageConfig.additionalFields.container_name) { + throw new Error('Enter the container name before testing the connection.'); + } + if (!blobStorageConfig.auth.key && !this.isEditMode) { + throw new Error('Enter the storage connection string before testing the connection.'); + } + return blobStorageConfig; + } + + if (testKey === 'databricks') { + const databricksConfig = this.getDatabricksConfiguration(); + if (!databricksConfig.endpoint) { + throw new Error('Enter the Databricks workspace URL before testing the connection.'); + } + if (!databricksConfig.additionalFields.warehouse_id) { + throw new Error('Enter the SQL warehouse ID before testing the connection.'); + } + return databricksConfig; + } + + if (testKey === 'logAnalytics') { + const logAnalyticsConfig = this.getLogAnalyticsConfiguration(); + if (!logAnalyticsConfig.additionalFields.workspaceId) { + throw new Error('Enter the Log Analytics workspace ID before testing the connection.'); + } + return logAnalyticsConfig; + } + + if (testKey === 'mcp') { + const mcpConfig = this.getMcpConfiguration(); + const transport = mcpConfig.additionalFields?.transport; + if (transport !== 'stdio' && !mcpConfig.endpoint) { + throw new Error('Enter the MCP server endpoint before testing the connection.'); + } + return mcpConfig; + } + + if (testKey === 'snowflake') { + const snowflakeConfig = this.getSnowflakeConfiguration(); + if (!snowflakeConfig.additionalFields.account) { + throw new Error('Enter the Snowflake account identifier before testing the connection.'); + } + if (!snowflakeConfig.additionalFields.warehouse) { + throw new Error('Enter the Snowflake warehouse before testing the connection.'); + } + return snowflakeConfig; + } + + if (testKey === 'tableau') { + const tableauConfig = this.getTableauConfiguration(); + if (!tableauConfig.endpoint) { + throw new Error('Enter the Tableau server URL before testing the connection.'); + } + return tableauConfig; + } + + throw new Error('This action type does not support connection testing.'); + } + + buildActionConnectionTestPayload(testKey) { + const typeConfig = this.buildActionConnectionTestConfig(testKey); + const payload = { + name: document.getElementById('plugin-name')?.value.trim() || '', + displayName: document.getElementById('plugin-display-name')?.value.trim() || '', + description: document.getElementById('plugin-description')?.value.trim() || '', + type: this.selectedType || '', + endpoint: typeConfig.endpoint || '', + auth: typeConfig.auth || {}, + metadata: {}, + additionalFields: typeConfig.additionalFields || {}, + action_scope: this.actionIdentityScope?.scope || 'personal' + }; + + if (typeConfig.identityId) { + payload.identity_id = typeConfig.identityId; + } + + const pluginContext = this.getTestPluginContext(); + if (pluginContext) { + payload.plugin_context = pluginContext; + } + + return payload; + } + + async runActionConnectionTest(testKey) { + const elements = this.getActionConnectionTestElements(testKey); + if (!elements || !elements.button) { + return; + } + + let payload; + try { + payload = this.buildActionConnectionTestPayload(testKey); + } catch (error) { + this.showActionConnectionTestMessage( + testKey, + 'warning', + error.message || 'Complete the required configuration fields before testing the connection.', + 'bi bi-exclamation-triangle' + ); + return; + } + + const originalChildNodes = Array.from(elements.button.childNodes); + elements.button.disabled = true; + elements.button.replaceChildren(); + const spinner = document.createElement('span'); + spinner.className = 'spinner-border spinner-border-sm me-2'; + spinner.setAttribute('role', 'status'); + spinner.setAttribute('aria-hidden', 'true'); + elements.button.append(spinner, document.createTextNode('Testing...')); + this.clearActionConnectionTestResult(testKey); + + try { + const response = await fetch(elements.config.url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + const data = await response.json().catch(() => ({})); + + if (response.ok && data.success) { + this.showActionConnectionTestMessage( + testKey, + 'success', + data.message || 'Connection successful.', + 'bi bi-check-circle' + ); + } else { + this.showActionConnectionTestMessage( + testKey, + 'danger', + data.error || `The ${elements.config.label} connection test failed.`, + 'bi bi-x-circle' + ); + } + } catch (error) { + this.showActionConnectionTestMessage( + testKey, + 'danger', + `Test failed: ${error.message || 'Network error'}`, + 'bi bi-x-circle' + ); + } finally { + elements.button.replaceChildren(...originalChildNodes); + elements.button.disabled = false; + } + } + initializeSqlConfiguration() { // Set default values document.getElementById('sql-read-only').value = 'true'; @@ -5692,6 +6279,8 @@ export class PluginModalStepper { } else if (this.isAzureMapsType(plugin.type)) { const auth = plugin.auth || {}; document.getElementById('azure-maps-key').value = auth.key || ''; + } else if (this.isLogAnalyticsType(plugin.type)) { + this.populateLogAnalyticsConfiguration(plugin); } else if (this.isChartType(plugin.type)) { const additionalFields = plugin.additionalFields || plugin.additional_fields || {}; this.setChartCapabilities(additionalFields.chart_capabilities || plugin.chart_capabilities || null); @@ -5740,6 +6329,7 @@ export class PluginModalStepper { const yamcsSection = document.getElementById('yamcs-config-section'); const mcpSection = document.getElementById('mcp-config-section'); const azureMapsSection = document.getElementById('azure-maps-config-section'); + const logAnalyticsSection = document.getElementById('log-analytics-config-section'); const isOpenApiVisible = !openApiSection.classList.contains('d-none'); const isSqlVisible = !sqlSection.classList.contains('d-none'); const isCosmosVisible = !cosmosSection.classList.contains('d-none'); @@ -5752,6 +6342,7 @@ export class PluginModalStepper { const isYamcsVisible = !yamcsSection.classList.contains('d-none'); const isMcpVisible = !mcpSection.classList.contains('d-none'); const isAzureMapsVisible = !azureMapsSection.classList.contains('d-none'); + const isLogAnalyticsVisible = !!logAnalyticsSection && !logAnalyticsSection.classList.contains('d-none'); let auth = {}; let endpoint = ''; @@ -5778,45 +6369,10 @@ export class PluginModalStepper { additionalFields.openapi_source_type = 'content'; // Changed from 'file' additionalFields.base_url = endpoint; - const selectedIdentity = this.getSelectedActionIdentity('openapi'); - const authType = document.getElementById('plugin-auth-type').value; - - if (selectedIdentity) { - identityId = selectedIdentity.id || selectedIdentity.identity_id || ''; - auth.type = 'identity'; - auth.identity = identityId; - additionalFields.identity_auth_type = this.getIdentityAuthType(selectedIdentity); - additionalFields.auth_method = 'identity'; - } else if (authType === 'api_key') { - // Map api_key to 'key' type for schema compliance - auth.type = 'key'; - const apiKeyValue = document.getElementById('plugin-auth-api-key-value').value.trim(); - auth.key = apiKeyValue; - - // Store API key configuration details in additionalFields instead of auth object - const apiKeyLocation = document.getElementById('plugin-auth-api-key-location').value; - const apiKeyName = document.getElementById('plugin-auth-api-key-name').value.trim(); - additionalFields.api_key_location = apiKeyLocation; - additionalFields.api_key_name = apiKeyName; - } else if (authType === 'bearer') { - auth.type = 'key'; // Bearer tokens are also 'key' type in the schema - auth.key = document.getElementById('plugin-auth-bearer-token').value.trim(); - additionalFields.auth_method = 'bearer'; - } else if (authType === 'basic') { - auth.type = 'key'; // Basic auth is also 'key' type in the schema - const username = document.getElementById('plugin-auth-basic-username').value.trim(); - const password = document.getElementById('plugin-auth-basic-password').value.trim(); - auth.key = `${username}:${password}`; // Store as combined string - additionalFields.auth_method = 'basic'; - } else if (authType === 'oauth2') { - auth.type = 'key'; // OAuth2 is also 'key' type in the schema - auth.key = document.getElementById('plugin-auth-oauth2-token').value.trim(); - additionalFields.auth_method = 'oauth2'; - } else if (authType === 'none') { - auth.type = 'key'; - auth.key = ''; // Empty key for no auth - additionalFields.auth_method = 'none'; - } + const openApiAuthConfig = this.getOpenApiAuthConfiguration(); + auth = openApiAuthConfig.auth; + identityId = openApiAuthConfig.identityId; + Object.assign(additionalFields, openApiAuthConfig.additionalFields); } else if (isSqlVisible) { // Collect SQL plugin data const databaseType = document.querySelector('input[name="sql-database-type"]:checked')?.value; @@ -5990,20 +6546,10 @@ export class PluginModalStepper { auth.type = 'NoAuth'; additionalFields = this.getDocumentSearchAdditionalFields(); } else if (isBlobStorageVisible) { - const connectionString = document.getElementById('blob-storage-connection-string').value.trim(); - const containerName = document.getElementById('blob-storage-container-name').value.trim(); - const blobPrefix = this.normalizeBlobStoragePrefix(document.getElementById('blob-storage-blob-prefix').value); - - auth.type = 'connection_string'; - auth.key = connectionString; - endpoint = this.deriveBlobStorageEndpointFromConnectionString(connectionString) || this.originalPlugin?.endpoint || ''; - additionalFields.container_name = containerName; - if (blobPrefix) { - additionalFields.blob_prefix = blobPrefix; - } - additionalFields.blob_storage_capabilities = this.getSelectedBlobStorageCapabilities(); - additionalFields.blob_storage_read_file_types = this.getSelectedBlobStorageReadFileTypes(); - additionalFields.blob_storage_upload_file_types = this.getSelectedBlobStorageUploadFileTypes(); + const blobStorageConfig = this.getBlobStorageConfiguration(); + endpoint = blobStorageConfig.endpoint; + auth = blobStorageConfig.auth; + additionalFields = blobStorageConfig.additionalFields; } else if (isDatabricksVisible) { const databricksConfig = this.getDatabricksConfiguration(); endpoint = databricksConfig.endpoint; @@ -6045,9 +6591,15 @@ export class PluginModalStepper { Object.assign(additionalFields, this.getMsGraphMailSendConfiguration()); Object.assign(additionalFields, this.getMsGraphCalendarSendConfiguration()); } else if (isAzureMapsVisible) { - endpoint = AZURE_MAPS_DEFAULT_ENDPOINT; - auth.type = 'key'; - auth.key = document.getElementById('azure-maps-key').value.trim(); + const azureMapsConfig = this.getAzureMapsConfiguration(); + endpoint = azureMapsConfig.endpoint; + auth = azureMapsConfig.auth; + } else if (isLogAnalyticsVisible) { + const logAnalyticsConfig = this.getLogAnalyticsConfiguration(); + endpoint = logAnalyticsConfig.endpoint; + auth = logAnalyticsConfig.auth; + additionalFields = logAnalyticsConfig.additionalFields; + identityId = logAnalyticsConfig.identityId; } else if (this.isChartType()) { endpoint = CHART_DEFAULT_ENDPOINT; auth.type = 'user'; @@ -6157,6 +6709,7 @@ export class PluginModalStepper { const isMsGraphType = this.isMsGraphType(); const isAzureMapsType = this.isAzureMapsType(); const isChartType = this.isChartType(); + const isLogAnalyticsType = this.isLogAnalyticsType(); const endpointRow = document.getElementById('summary-plugin-endpoint-row'); const databaseTypeRow = document.getElementById('summary-plugin-database-type-row'); @@ -6223,6 +6776,12 @@ export class PluginModalStepper { endpointRow.style.display = 'none'; document.getElementById('summary-plugin-database-type').textContent = 'Azure Maps tile proxy'; databaseTypeRow.style.display = ''; + } else if (isLogAnalyticsType) { + const endpoint = this.getEndpointValue(); + document.getElementById('summary-plugin-endpoint').textContent = endpoint || '-'; + endpointRow.style.display = ''; + document.getElementById('summary-plugin-database-type').textContent = 'Azure Log Analytics workspace'; + databaseTypeRow.style.display = ''; } else if (isChartType) { endpointRow.style.display = 'none'; document.getElementById('summary-plugin-database-type').textContent = 'Built-in chart action'; @@ -6249,10 +6808,10 @@ export class PluginModalStepper { } const databaseType = this.getSqlDatabaseType(); - if (!isSqlType && !isCosmosType && !isRocksDbType && !isDocumentSearchType && !isBlobStorageType && !isDatabricksType && !isSnowflakeType && !isTableauType && !isYamcsType && !isMcpType && !isSimpleChatType && !isMsGraphType && !isAzureMapsType && !isChartType && databaseType) { + if (!isSqlType && !isCosmosType && !isRocksDbType && !isDocumentSearchType && !isBlobStorageType && !isDatabricksType && !isSnowflakeType && !isTableauType && !isYamcsType && !isMcpType && !isSimpleChatType && !isMsGraphType && !isAzureMapsType && !isChartType && !isLogAnalyticsType && databaseType) { document.getElementById('summary-plugin-database-type').textContent = databaseType; databaseTypeRow.style.display = ''; - } else if (!isSqlType && !isCosmosType && !isRocksDbType && !isDocumentSearchType && !isBlobStorageType && !isDatabricksType && !isSnowflakeType && !isTableauType && !isYamcsType && !isMcpType && !isSimpleChatType && !isMsGraphType && !isAzureMapsType && !isChartType) { + } else if (!isSqlType && !isCosmosType && !isRocksDbType && !isDocumentSearchType && !isBlobStorageType && !isDatabricksType && !isSnowflakeType && !isTableauType && !isYamcsType && !isMcpType && !isSimpleChatType && !isMsGraphType && !isAzureMapsType && !isChartType && !isLogAnalyticsType) { databaseTypeRow.style.display = 'none'; } @@ -6290,6 +6849,7 @@ export class PluginModalStepper { const isMsGraphType = this.isMsGraphType(); const isAzureMapsType = this.isAzureMapsType(); const isChartType = this.isChartType(); + const isLogAnalyticsType = this.isLogAnalyticsType(); if (isOpenApiType) { return document.getElementById('plugin-endpoint').value.trim(); @@ -6317,6 +6877,8 @@ export class PluginModalStepper { return transport === 'stdio' ? MCP_STDIO_ENDPOINT : document.getElementById('mcp-endpoint').value.trim(); } else if (isAzureMapsType) { return AZURE_MAPS_DEFAULT_ENDPOINT; + } else if (isLogAnalyticsType) { + return this.getLogAnalyticsConfiguration().endpoint; } else if (isMsGraphType) { return MSGRAPH_DEFAULT_ENDPOINT; } else if (isChartType) { @@ -6341,6 +6903,7 @@ export class PluginModalStepper { const isMsGraphType = this.isMsGraphType(); const isAzureMapsType = this.isAzureMapsType(); const isChartType = this.isChartType(); + const isLogAnalyticsType = this.isLogAnalyticsType(); if (isOpenApiType) { const authType = document.getElementById('plugin-auth-type').value; @@ -6388,6 +6951,11 @@ export class PluginModalStepper { return 'User'; } else if (isAzureMapsType) { return 'Subscription Key'; + } else if (isLogAnalyticsType) { + if (this.getSelectedActionIdentity('logAnalytics')) { + return 'Reusable Identity'; + } + return this.formatAuthType(document.getElementById('log-analytics-auth-method')?.value || 'identity'); } else if (isChartType) { return 'User'; } else { @@ -6947,6 +7515,7 @@ export class PluginModalStepper { const isMsGraphType = this.isMsGraphType(); const isAzureMapsType = this.isAzureMapsType(); const isChartType = this.isChartType(); + const isLogAnalyticsType = this.isLogAnalyticsType(); if (isOpenApiType) { currentEndpoint = document.getElementById('plugin-endpoint')?.value || ''; @@ -6972,6 +7541,8 @@ export class PluginModalStepper { currentEndpoint = MSGRAPH_DEFAULT_ENDPOINT; } else if (isAzureMapsType) { currentEndpoint = AZURE_MAPS_DEFAULT_ENDPOINT; + } else if (isLogAnalyticsType) { + currentEndpoint = this.getLogAnalyticsConfiguration().endpoint; } else if (isChartType) { currentEndpoint = CHART_DEFAULT_ENDPOINT; } else { @@ -7044,6 +7615,12 @@ export class PluginModalStepper { } else if (isAzureMapsType) { currentAuthType = 'key'; currentAuthKey = document.getElementById('azure-maps-key')?.value || ''; + } else if (isLogAnalyticsType) { + const selectedIdentity = this.getSelectedActionIdentity('logAnalytics'); + currentAuthType = selectedIdentity ? 'identity' : (document.getElementById('log-analytics-auth-method')?.value || 'identity'); + if (!selectedIdentity && ['servicePrincipal', 'key'].includes(currentAuthType)) { + currentAuthKey = document.getElementById('log-analytics-auth-key')?.value || ''; + } } else if (isChartType) { currentAuthType = 'user'; } else { @@ -7086,6 +7663,8 @@ export class PluginModalStepper { }, null, 2); } else if (isAzureMapsType) { currentAdditionalFields = '{}'; + } else if (isLogAnalyticsType) { + currentAdditionalFields = JSON.stringify(this.getLogAnalyticsConfiguration().additionalFields, null, 2); } else if (isChartType) { currentAdditionalFields = JSON.stringify({ chart_capabilities: this.getSelectedChartCapabilities() @@ -7368,6 +7947,18 @@ export class PluginModalStepper { safeSetValue('plugin-auth-oauth2-token-generic'); safeSetValue('azure-maps-key'); + // Step 3 fields - Log Analytics Plugin + safeSetValue('log-analytics-workspace-id'); + safeSetValue('log-analytics-cloud', LOG_ANALYTICS_DEFAULT_CLOUD); + safeSetValue('log-analytics-endpoint'); + safeSetValue('log-analytics-authority-host'); + safeSetValue('log-analytics-endpoint-override'); + safeSetValue('log-analytics-identity-select'); + safeSetValue('log-analytics-auth-method', 'identity'); + safeSetValue('log-analytics-auth-identity'); + safeSetValue('log-analytics-auth-key'); + safeSetValue('log-analytics-auth-tenant-id'); + // Step 3 fields - MCP Plugin safeSetValue('mcp-preconfiguration'); safeSetValue('mcp-server-profile', this.mcpDefaultServerPreset || MCP_DEFAULT_SERVER_PROFILE); diff --git a/application/single_app/templates/_plugin_modal.html b/application/single_app/templates/_plugin_modal.html index 74988d498..8cbe78851 100644 --- a/application/single_app/templates/_plugin_modal.html +++ b/application/single_app/templates/_plugin_modal.html @@ -166,6 +166,17 @@