From 2f8ff949fc052442a8a6bd587aa8f249580c4d4f Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Wed, 15 Oct 2025 02:58:55 -0600 Subject: [PATCH] Allow selecting svcBorealis account for playbooks --- .../WebUI/src/Scheduling/Create_Job.jsx | 63 ++++++++++++++++--- .../Server/WebUI/src/Scheduling/Quick_Job.jsx | 47 +++++++++++--- Data/Server/job_scheduler.py | 56 ++++++++++++----- Data/Server/server.py | 29 ++++++--- 4 files changed, 154 insertions(+), 41 deletions(-) diff --git a/Data/Server/WebUI/src/Scheduling/Create_Job.jsx b/Data/Server/WebUI/src/Scheduling/Create_Job.jsx index e281867..89b0996 100644 --- a/Data/Server/WebUI/src/Scheduling/Create_Job.jsx +++ b/Data/Server/WebUI/src/Scheduling/Create_Job.jsx @@ -429,6 +429,7 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) { const [credentialLoading, setCredentialLoading] = useState(false); const [credentialError, setCredentialError] = useState(""); const [selectedCredentialId, setSelectedCredentialId] = useState(""); + const [useSvcAccount, setUseSvcAccount] = useState(true); const loadCredentials = useCallback(async () => { setCredentialLoading(true); @@ -453,6 +454,16 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) { }, [loadCredentials]); const remoteExec = useMemo(() => execContext === "ssh" || execContext === "winrm", [execContext]); + const handleExecContextChange = useCallback((value) => { + const normalized = String(value || "system").toLowerCase(); + setExecContext(normalized); + if (normalized === "winrm") { + setUseSvcAccount(true); + setSelectedCredentialId(""); + } else { + setUseSvcAccount(false); + } + }, []); const filteredCredentials = useMemo(() => { if (!remoteExec) return credentials; const target = execContext === "winrm" ? "winrm" : "ssh"; @@ -463,6 +474,10 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) { if (!remoteExec) { return; } + if (execContext === "winrm" && useSvcAccount) { + setSelectedCredentialId(""); + return; + } if (!filteredCredentials.length) { setSelectedCredentialId(""); return; @@ -470,7 +485,7 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) { if (!selectedCredentialId || !filteredCredentials.some((cred) => String(cred.id) === String(selectedCredentialId))) { setSelectedCredentialId(String(filteredCredentials[0].id)); } - }, [remoteExec, filteredCredentials, selectedCredentialId]); + }, [remoteExec, filteredCredentials, selectedCredentialId, execContext, useSvcAccount]); // dialogs state const [addCompOpen, setAddCompOpen] = useState(false); @@ -877,12 +892,13 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) { const isValid = useMemo(() => { const base = jobName.trim().length > 0 && components.length > 0 && targets.length > 0; if (!base) return false; - if (remoteExec && !selectedCredentialId) return false; + const needsCredential = remoteExec && !(execContext === "winrm" && useSvcAccount); + if (needsCredential && !selectedCredentialId) return false; if (scheduleType !== "immediately") { return !!startDateTime; } return true; - }, [jobName, components.length, targets.length, scheduleType, startDateTime, remoteExec, selectedCredentialId]); + }, [jobName, components.length, targets.length, scheduleType, startDateTime, remoteExec, selectedCredentialId, execContext, useSvcAccount]); const [confirmOpen, setConfirmOpen] = useState(false); const editing = !!(initialJob && initialJob.id); @@ -1358,6 +1374,11 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) { setExpiration(initialJob.expiration || "no_expire"); setExecContext(initialJob.execution_context || "system"); setSelectedCredentialId(initialJob.credential_id ? String(initialJob.credential_id) : ""); + if ((initialJob.execution_context || "").toLowerCase() === "winrm") { + setUseSvcAccount(initialJob.use_service_account !== false); + } else { + setUseSvcAccount(false); + } const comps = Array.isArray(initialJob.components) ? initialJob.components : []; const hydrated = await hydrateExistingComponents(comps); if (!canceled) { @@ -1369,6 +1390,7 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) { setComponents([]); setComponentVarErrors({}); setSelectedCredentialId(""); + setUseSvcAccount(true); } }; hydrate(); @@ -1464,7 +1486,7 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) { }; const handleCreate = async () => { - if (remoteExec && !selectedCredentialId) { + if (remoteExec && !(execContext === "winrm" && useSvcAccount) && !selectedCredentialId) { alert("Please select a credential for this execution context."); return; } @@ -1496,7 +1518,8 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) { schedule: { type: scheduleType, start: scheduleType !== "immediately" ? (() => { try { const d = startDateTime?.toDate?.() || new Date(startDateTime); d.setSeconds(0,0); return d.toISOString(); } catch { return startDateTime; } })() : null }, duration: { stopAfterEnabled, expiration }, execution_context: execContext, - credential_id: remoteExec && selectedCredentialId ? Number(selectedCredentialId) : null + credential_id: remoteExec && !useSvcAccount && selectedCredentialId ? Number(selectedCredentialId) : null, + use_service_account: execContext === "winrm" ? Boolean(useSvcAccount) : false }; try { const resp = await fetch(initialJob && initialJob.id ? `/api/scheduled_jobs/${initialJob.id}` : "/api/scheduled_jobs", { @@ -1726,7 +1749,7 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) { {remoteExec && ( + {execContext === "winrm" && ( + { + const checked = e.target.checked; + setUseSvcAccount(checked); + if (checked) { + setSelectedCredentialId(""); + } else if (!selectedCredentialId && filteredCredentials.length) { + setSelectedCredentialId(String(filteredCredentials[0].id)); + } + }} + /> + } + label="Use Configured svcBorealis Account" + /> + )} Credential + {useSvcAccount && ( + + Runs with the agent's svcBorealis account. + + )} {credentialsLoading && } {!credentialsLoading && credentialsError && ( {credentialsError} )} - {!credentialsLoading && !credentialsError && !credentials.length && ( + {!useSvcAccount && !credentialsLoading && !credentialsError && !credentials.length && ( 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 e0ae58a..eb72788 100644 --- a/Data/Server/job_scheduler.py +++ b/Data/Server/job_scheduler.py @@ -511,6 +511,7 @@ class JobScheduler: scheduled_run_row_id: int, run_mode: str, credential_id: Optional[int] = None, + use_service_account: bool = False, ) -> Optional[Dict[str, Any]]: try: import os, uuid @@ -551,7 +552,7 @@ class JobScheduler: server_run = run_mode_norm == "ssh" agent_winrm = run_mode_norm == "winrm" - if agent_winrm: + if agent_winrm and not use_service_account: if not credential_id: raise RuntimeError("WinRM execution requires a credential_id") if not callable(self._credential_fetcher): @@ -1000,7 +1001,7 @@ class JobScheduler: pass try: cur.execute( - "SELECT id, components_json, targets_json, schedule_type, start_ts, expiration, execution_context, credential_id, created_at FROM scheduled_jobs WHERE enabled=1 ORDER BY id ASC" + "SELECT id, components_json, targets_json, schedule_type, start_ts, expiration, execution_context, credential_id, use_service_account, created_at FROM scheduled_jobs WHERE enabled=1 ORDER BY id ASC" ) jobs = cur.fetchall() except Exception: @@ -1018,7 +1019,18 @@ class JobScheduler: five_min = 300 now_min = _now_minute() - for (job_id, components_json, targets_json, schedule_type, start_ts, expiration, execution_context, credential_id, created_at) in jobs: + for ( + job_id, + components_json, + targets_json, + schedule_type, + start_ts, + expiration, + execution_context, + credential_id, + use_service_account_flag, + created_at, + ) in jobs: try: # Targets list for this job try: @@ -1054,6 +1066,9 @@ class JobScheduler: continue run_mode = (execution_context or "system").strip().lower() job_credential_id = None + job_use_service_account = bool(use_service_account_flag) + if run_mode != "winrm": + job_use_service_account = False try: job_credential_id = int(credential_id) if credential_id is not None else None except Exception: @@ -1144,7 +1159,7 @@ class JobScheduler: run_row_id = c2.lastrowid or 0 conn2.commit() activity_links: List[Dict[str, Any]] = [] - remote_requires_cred = run_mode in ("ssh", "winrm") + remote_requires_cred = (run_mode == "ssh") or (run_mode == "winrm" and not job_use_service_account) if remote_requires_cred and not job_credential_id: err_msg = "Credential required for remote execution" c2.execute( @@ -1178,6 +1193,7 @@ class JobScheduler: run_row_id, run_mode, job_credential_id, + job_use_service_account, ) if link and link.get("activity_id"): activity_links.append({ @@ -1289,9 +1305,10 @@ class JobScheduler: "expiration": r[7] or "no_expire", "execution_context": r[8] or "system", "credential_id": r[9], - "enabled": bool(r[10] or 0), - "created_at": r[11] or 0, - "updated_at": r[12] or 0, + "use_service_account": bool(r[10] or 0), + "enabled": bool(r[11] or 0), + "created_at": r[12] or 0, + "updated_at": r[13] or 0, } # Attach computed status summary for latest occurrence try: @@ -1368,7 +1385,8 @@ class JobScheduler: cur.execute( """ SELECT id, name, components_json, targets_json, schedule_type, start_ts, - duration_stop_enabled, expiration, execution_context, credential_id, enabled, created_at, updated_at + duration_stop_enabled, expiration, execution_context, credential_id, + use_service_account, enabled, created_at, updated_at FROM scheduled_jobs ORDER BY created_at DESC """ @@ -1396,6 +1414,8 @@ class JobScheduler: credential_id = int(credential_id) if credential_id is not None else None except Exception: credential_id = None + use_service_account_raw = data.get("use_service_account") + use_service_account = 1 if (execution_context == "winrm" and (use_service_account_raw is None or bool(use_service_account_raw))) else 0 enabled = int(bool(data.get("enabled", True))) if not name or not components or not targets: return json.dumps({"error": "name, components, targets required"}), 400, {"Content-Type": "application/json"} @@ -1406,8 +1426,8 @@ class JobScheduler: cur.execute( """ INSERT INTO scheduled_jobs - (name, components_json, targets_json, schedule_type, start_ts, duration_stop_enabled, expiration, execution_context, credential_id, enabled, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + (name, components_json, targets_json, schedule_type, start_ts, duration_stop_enabled, expiration, execution_context, credential_id, use_service_account, enabled, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) """, ( name, @@ -1419,6 +1439,7 @@ class JobScheduler: expiration, execution_context, credential_id, + use_service_account, enabled, now, now, @@ -1429,7 +1450,7 @@ class JobScheduler: cur.execute( """ SELECT id, name, components_json, targets_json, schedule_type, start_ts, - duration_stop_enabled, expiration, execution_context, credential_id, enabled, created_at, updated_at + duration_stop_enabled, expiration, execution_context, credential_id, use_service_account, enabled, created_at, updated_at FROM scheduled_jobs WHERE id=? """, (job_id,), @@ -1448,7 +1469,7 @@ class JobScheduler: cur.execute( """ SELECT id, name, components_json, targets_json, schedule_type, start_ts, - duration_stop_enabled, expiration, execution_context, credential_id, enabled, created_at, updated_at + duration_stop_enabled, expiration, execution_context, credential_id, use_service_account, enabled, created_at, updated_at FROM scheduled_jobs WHERE id=? """, (job_id,), @@ -1481,7 +1502,10 @@ class JobScheduler: if "expiration" in data or (data.get("duration") and "expiration" in data.get("duration")): fields["expiration"] = (data.get("duration") or {}).get("expiration") or data.get("expiration") or "no_expire" if "execution_context" in data: - fields["execution_context"] = (data.get("execution_context") or "system").strip().lower() + exec_ctx_val = (data.get("execution_context") or "system").strip().lower() + fields["execution_context"] = exec_ctx_val + if exec_ctx_val != "winrm": + fields["use_service_account"] = 0 if "credential_id" in data: cred_val = data.get("credential_id") if cred_val in (None, "", "null"): @@ -1491,6 +1515,8 @@ class JobScheduler: fields["credential_id"] = int(cred_val) except Exception: fields["credential_id"] = None + if "use_service_account" in data: + fields["use_service_account"] = 1 if bool(data.get("use_service_account")) else 0 if "enabled" in data: fields["enabled"] = int(bool(data.get("enabled"))) if not fields: @@ -1508,7 +1534,7 @@ class JobScheduler: cur.execute( """ SELECT id, name, components_json, targets_json, schedule_type, start_ts, - duration_stop_enabled, expiration, execution_context, credential_id, enabled, created_at, updated_at + duration_stop_enabled, expiration, execution_context, credential_id, use_service_account, enabled, created_at, updated_at FROM scheduled_jobs WHERE id=? """, (job_id,), @@ -1532,7 +1558,7 @@ class JobScheduler: return json.dumps({"error": "not found"}), 404, {"Content-Type": "application/json"} conn.commit() cur.execute( - "SELECT id, name, components_json, targets_json, schedule_type, start_ts, duration_stop_enabled, expiration, execution_context, credential_id, enabled, created_at, updated_at FROM scheduled_jobs WHERE id=?", + "SELECT id, name, components_json, targets_json, schedule_type, start_ts, duration_stop_enabled, expiration, execution_context, credential_id, use_service_account, enabled, created_at, updated_at FROM scheduled_jobs WHERE id=?", (job_id,), ) row = cur.fetchone() diff --git a/Data/Server/server.py b/Data/Server/server.py index 0893f99..0c117f6 100644 --- a/Data/Server/server.py +++ b/Data/Server/server.py @@ -4531,6 +4531,7 @@ def init_db(): expiration TEXT, execution_context TEXT NOT NULL, credential_id INTEGER, + use_service_account INTEGER NOT NULL DEFAULT 1, enabled INTEGER DEFAULT 1, created_at INTEGER, updated_at INTEGER @@ -4542,6 +4543,8 @@ def init_db(): sj_cols = [row[1] for row in cur.fetchall()] if "credential_id" not in sj_cols: cur.execute("ALTER TABLE scheduled_jobs ADD COLUMN credential_id INTEGER") + if "use_service_account" not in sj_cols: + cur.execute("ALTER TABLE scheduled_jobs ADD COLUMN use_service_account INTEGER NOT NULL DEFAULT 1") except Exception: pass conn.commit() @@ -6410,12 +6413,21 @@ def ansible_quick_run(): rel_path = (data.get("playbook_path") or "").strip() hostnames = data.get("hostnames") or [] credential_id = data.get("credential_id") + use_service_account_raw = data.get("use_service_account") if not rel_path or not isinstance(hostnames, list) or not hostnames: _ansible_log_server(f"[quick_run] invalid payload rel_path='{rel_path}' hostnames={hostnames}") return jsonify({"error": "Missing playbook_path or hostnames[]"}), 400 server_mode = False cred_id_int = None credential_detail: Optional[Dict[str, Any]] = None + overrides_raw = data.get("variable_values") + variable_values: Dict[str, Any] = {} + if isinstance(overrides_raw, dict): + for key, val in overrides_raw.items(): + name = str(key or "").strip() + if not name: + continue + variable_values[name] = val if credential_id not in (None, "", "null"): try: cred_id_int = int(credential_id) @@ -6423,7 +6435,13 @@ def ansible_quick_run(): cred_id_int = None except Exception: return jsonify({"error": "Invalid credential_id"}), 400 - + if use_service_account_raw is None: + use_service_account = cred_id_int is None + else: + use_service_account = bool(use_service_account_raw) + if use_service_account: + cred_id_int = None + credential_detail = None if cred_id_int: credential_detail = _fetch_credential_with_secrets(cred_id_int) if not credential_detail: @@ -6446,15 +6464,6 @@ def ansible_quick_run(): variables = doc.get('variables') if isinstance(doc.get('variables'), list) else [] files = doc.get('files') if isinstance(doc.get('files'), list) else [] friendly_name = (doc.get("name") or "").strip() or os.path.basename(abs_path) - overrides_raw = data.get("variable_values") - variable_values = {} - if isinstance(overrides_raw, dict): - for key, val in overrides_raw.items(): - name = str(key or "").strip() - if not name: - continue - variable_values[name] = val - if server_mode and not cred_id_int: return jsonify({"error": "credential_id is required for server-side execution"}), 400