From 74540b7f10a7495fcf9c46bcb660b73618f9479c Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Wed, 15 Oct 2025 01:20:54 -0600 Subject: [PATCH] Pass stored WinRM credentials to agent Ansible runs --- .../Server/WebUI/src/Scheduling/Quick_Job.jsx | 21 ++++-- Data/Server/job_scheduler.py | 52 +++++++++++++- Data/Server/server.py | 70 +++++++++++++++---- 3 files changed, 122 insertions(+), 21 deletions(-) diff --git a/Data/Server/WebUI/src/Scheduling/Quick_Job.jsx b/Data/Server/WebUI/src/Scheduling/Quick_Job.jsx index d83ac31..35359c9 100644 --- a/Data/Server/WebUI/src/Scheduling/Quick_Job.jsx +++ b/Data/Server/WebUI/src/Scheduling/Quick_Job.jsx @@ -136,7 +136,10 @@ export default function QuickJob({ open, onClose, hostnames = [] }) { const data = await resp.json(); if (canceled) return; const list = Array.isArray(data?.credentials) - ? data.credentials.filter((cred) => String(cred.connection_type || "").toLowerCase() === "ssh") + ? data.credentials.filter((cred) => { + const conn = String(cred.connection_type || "").toLowerCase(); + return conn === "ssh" || conn === "winrm"; + }) : []; list.sort((a, b) => String(a?.name || "").localeCompare(String(b?.name || ""))); setCredentials(list); @@ -435,11 +438,15 @@ export default function QuickJob({ open, onClose, hostnames = [] }) { onChange={(e) => setSelectedCredentialId(e.target.value)} sx={{ bgcolor: "#1f1f1f", color: "#fff" }} > - {credentials.map((cred) => ( - - {cred.name} - - ))} + {credentials.map((cred) => { + const conn = String(cred.connection_type || "").toUpperCase(); + return ( + + {cred.name} + {conn ? ` (${conn})` : ""} + + ); + })} {credentialsLoading && } @@ -448,7 +455,7 @@ export default function QuickJob({ open, onClose, hostnames = [] }) { )} {!credentialsLoading && !credentialsError && !credentials.length && ( - No SSH credentials available. Create one under Access Management. + No SSH or WinRM credentials available. Create one under Access Management. )} diff --git a/Data/Server/job_scheduler.py b/Data/Server/job_scheduler.py index bf95d77..e0ae58a 100644 --- a/Data/Server/job_scheduler.py +++ b/Data/Server/job_scheduler.py @@ -7,6 +7,10 @@ import re import sqlite3 from typing import Any, Dict, List, Optional, Tuple, Callable +_WINRM_USERNAME_VAR = "__borealis_winrm_username" +_WINRM_PASSWORD_VAR = "__borealis_winrm_password" +_WINRM_TRANSPORT_VAR = "__borealis_winrm_transport" + """ Job Scheduler module for Borealis @@ -54,6 +58,26 @@ def _decode_base64_text(value: Any) -> Optional[str]: return decoded.decode("utf-8", errors="replace") +def _inject_winrm_credential( + base_values: Optional[Dict[str, Any]], + credential: Optional[Dict[str, Any]], +) -> Dict[str, Any]: + values: Dict[str, Any] = dict(base_values or {}) + if not credential: + return values + + username = str(credential.get("username") or "") + password = str(credential.get("password") or "") + metadata = credential.get("metadata") if isinstance(credential.get("metadata"), dict) else {} + transport = metadata.get("winrm_transport") if isinstance(metadata, dict) else None + transport_str = str(transport or "ntlm").strip().lower() or "ntlm" + + values[_WINRM_USERNAME_VAR] = username + values[_WINRM_PASSWORD_VAR] = password + values[_WINRM_TRANSPORT_VAR] = transport_str + return values + + def _decode_script_content(value: Any, encoding_hint: str = "") -> str: encoding = (encoding_hint or "").strip().lower() if isinstance(value, str): @@ -311,6 +335,8 @@ class JobScheduler: self._online_lookup: Optional[Callable[[], List[str]]] = None # Optional callback to execute Ansible directly from the server self._server_ansible_runner: Optional[Callable[..., str]] = None + # Optional callback to fetch stored credentials (with decrypted secrets) + self._credential_fetcher: Optional[Callable[[int], Optional[Dict[str, Any]]]] = None # Ensure run-history table exists self._init_tables() @@ -522,7 +548,24 @@ class JobScheduler: variables = doc.get("variables") or [] files = doc.get("files") or [] run_mode_norm = (run_mode or "system").strip().lower() - server_run = run_mode_norm in ("ssh", "winrm") + server_run = run_mode_norm == "ssh" + agent_winrm = run_mode_norm == "winrm" + + if agent_winrm: + if not credential_id: + raise RuntimeError("WinRM execution requires a credential_id") + if not callable(self._credential_fetcher): + raise RuntimeError("Credential fetcher is not configured") + cred_detail = self._credential_fetcher(int(credential_id)) + if not cred_detail: + raise RuntimeError("Credential not found") + try: + overrides_map = _inject_winrm_credential(overrides_map, cred_detail) + finally: + try: + cred_detail.clear() # type: ignore[attr-defined] + except Exception: + pass # Record in activity_history for UI parity now = _now_ts() @@ -743,6 +786,9 @@ class JobScheduler: def _conn(self): return sqlite3.connect(self.db_path) + def set_credential_fetcher(self, fn: Optional[Callable[[int], Optional[Dict[str, Any]]]]): + self._credential_fetcher = fn + def _init_tables(self): conn = self._conn() cur = conn.cursor() @@ -1738,3 +1784,7 @@ def set_online_lookup(scheduler: JobScheduler, fn: Callable[[], List[str]]): def set_server_ansible_runner(scheduler: JobScheduler, fn: Callable[..., str]): scheduler._server_ansible_runner = fn + + +def set_credential_fetcher(scheduler: JobScheduler, fn: Callable[[int], Optional[Dict[str, Any]]]): + scheduler._credential_fetcher = fn diff --git a/Data/Server/server.py b/Data/Server/server.py index b111e9c..0893f99 100644 --- a/Data/Server/server.py +++ b/Data/Server/server.py @@ -329,6 +329,7 @@ from Python_API_Endpoints.script_engines import run_powershell_script from job_scheduler import register as register_job_scheduler from job_scheduler import set_online_lookup as scheduler_set_online_lookup from job_scheduler import set_server_ansible_runner as scheduler_set_server_runner +from job_scheduler import set_credential_fetcher as scheduler_set_credential_fetcher # ============================================================================= # Section: Runtime Stack Configuration @@ -1859,6 +1860,11 @@ def _ensure_ansible_workspace() -> str: return _ANSIBLE_WORKSPACE_DIR +_WINRM_USERNAME_VAR = "__borealis_winrm_username" +_WINRM_PASSWORD_VAR = "__borealis_winrm_password" +_WINRM_TRANSPORT_VAR = "__borealis_winrm_transport" + + def _fetch_credential_with_secrets(credential_id: int) -> Optional[Dict[str, Any]]: try: conn = _db_conn() @@ -1876,7 +1882,8 @@ def _fetch_credential_with_secrets(credential_id: int) -> Optional[Dict[str, Any private_key_passphrase_encrypted, become_method, become_username, - become_password_encrypted + become_password_encrypted, + metadata_json FROM credentials WHERE id=? """, @@ -1900,8 +1907,40 @@ def _fetch_credential_with_secrets(credential_id: int) -> Optional[Dict[str, Any "become_method": _normalize_become_method(row[8]), "become_username": row[9] or "", "become_password": _decrypt_secret(row[10]) if row[10] else "", + "metadata": {}, } + try: + meta_json = row[11] if len(row) > 11 else None + if meta_json: + meta = json.loads(meta_json) + if isinstance(meta, dict): + cred["metadata"] = meta + except Exception: + pass + + return cred + + +def _inject_winrm_credential( + base_values: Optional[Dict[str, Any]], + credential: Optional[Dict[str, Any]], +) -> Dict[str, Any]: + values: Dict[str, Any] = dict(base_values or {}) + if not credential: + return values + + username = str(credential.get("username") or "") + password = str(credential.get("password") or "") + metadata = credential.get("metadata") if isinstance(credential.get("metadata"), dict) else {} + transport = metadata.get("winrm_transport") if isinstance(metadata, dict) else None + transport_str = str(transport or "ntlm").strip().lower() or "ntlm" + + values[_WINRM_USERNAME_VAR] = username + values[_WINRM_PASSWORD_VAR] = password + values[_WINRM_TRANSPORT_VAR] = transport_str + return values + def _emit_ansible_recap_from_row(row): if not row: @@ -4564,6 +4603,7 @@ ensure_default_admin() job_scheduler = register_job_scheduler(app, socketio, DB_PATH) scheduler_set_server_runner(job_scheduler, _queue_server_ansible_run) +scheduler_set_credential_fetcher(job_scheduler, _fetch_credential_with_secrets) job_scheduler.start() # Provide scheduler with online device lookup based on registered agents @@ -6375,15 +6415,26 @@ def ansible_quick_run(): return jsonify({"error": "Missing playbook_path or hostnames[]"}), 400 server_mode = False cred_id_int = None + credential_detail: Optional[Dict[str, Any]] = None if credential_id not in (None, "", "null"): try: cred_id_int = int(credential_id) if cred_id_int <= 0: cred_id_int = None - else: - server_mode = True except Exception: return jsonify({"error": "Invalid credential_id"}), 400 + + if cred_id_int: + credential_detail = _fetch_credential_with_secrets(cred_id_int) + if not credential_detail: + return jsonify({"error": "Credential not found"}), 404 + conn_type = (credential_detail.get("connection_type") or "ssh").lower() + if conn_type in ("ssh", "linux", "unix"): + server_mode = True + elif conn_type in ("winrm", "psrp"): + variable_values = _inject_winrm_credential(variable_values, credential_detail) + else: + return jsonify({"error": f"Credential connection '{conn_type}' not supported"}), 400 try: root, abs_path, _ = _resolve_assembly_path('ansible', rel_path) if not os.path.isfile(abs_path): @@ -6407,16 +6458,6 @@ def ansible_quick_run(): if server_mode and not cred_id_int: return jsonify({"error": "credential_id is required for server-side execution"}), 400 - if server_mode: - cred = _fetch_credential_with_secrets(cred_id_int) - if not cred: - return jsonify({"error": "Credential not found"}), 404 - conn_type = (cred.get("connection_type") or "ssh").lower() - if conn_type not in ("ssh",): - return jsonify({"error": f"Credential connection '{conn_type}' not supported for server execution"}), 400 - # Avoid keeping decrypted secrets in memory longer than necessary - del cred - results = [] for host in hostnames: # Create activity_history row so UI shows running state and can receive recap mirror @@ -6510,6 +6551,9 @@ def ansible_quick_run(): except Exception: pass results.append({"hostname": host, "run_id": run_id, "status": "Failed", "activity_job_id": job_id, "error": str(ex)}) + if credential_detail is not None: + # Remove decrypted secrets from scope as soon as possible + credential_detail.clear() return jsonify({"results": results}) except ValueError as ve: return jsonify({"error": str(ve)}), 400