From cd2c3b5302b84639c0bca28c9cef1f17804993d0 Mon Sep 17 00:00:00 2001 From: Ed Clark Date: Fri, 31 Jul 2026 10:16:00 -0400 Subject: [PATCH 1/4] feat(rmf): Collect from Azure in evidence tab - Add start_rmf_collect / get_rmf_collect_job to functions_rmf.py - Add POST /api/rmf/workspace/evidence/collect and GET .../collect/jobs/ routes to route_backend_rmf.py - Add rmf-collect.js: form wiring, job polling, live log, evidence refresh - Add Collect from Azure card to evidence tab in rmf_workspace.html (subscription ID, resource group, agentic toggle, status badge + log) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3402ff43-7857-401e-99f5-9a34dd704d0e --- application/single_app/functions_rmf.py | 25 +++ application/single_app/route_backend_rmf.py | 45 ++++++ .../single_app/static/js/rmf/rmf-collect.js | 153 ++++++++++++++++++ .../single_app/templates/rmf_workspace.html | 60 ++++++- 4 files changed, 282 insertions(+), 1 deletion(-) create mode 100644 application/single_app/static/js/rmf/rmf-collect.js diff --git a/application/single_app/functions_rmf.py b/application/single_app/functions_rmf.py index 82b9fc6f8..5959947e7 100644 --- a/application/single_app/functions_rmf.py +++ b/application/single_app/functions_rmf.py @@ -968,6 +968,31 @@ def initialize_rmf_service(group_id, user_id, group_role, payload): ) +def start_rmf_collect(group_id, user_id, group_role, subscription_id, resource_group, agentic): + return _rmf_request( + "POST", + "/api/v1/workspaces/current/collect", + group_id, + user_id, + group_role, + payload={ + "subscription_id": subscription_id, + "resource_group": resource_group or None, + "agentic": bool(agentic), + }, + ) + + +def get_rmf_collect_job(group_id, user_id, group_role, job_id): + return _rmf_request( + "GET", + f"/api/v1/workspaces/current/collect/jobs/{job_id}", + group_id, + user_id, + group_role, + ) + + def update_group_rmf_setup_status(group_id, setup_status): group_doc = find_group_by_id(group_id) if not group_doc: diff --git a/application/single_app/route_backend_rmf.py b/application/single_app/route_backend_rmf.py index 1389d357d..a389093cd 100644 --- a/application/single_app/route_backend_rmf.py +++ b/application/single_app/route_backend_rmf.py @@ -43,6 +43,7 @@ get_rmf_chat_session, get_rmf_chat_sessions, get_rmf_chat_source, + get_rmf_collect_job, get_rmf_control, get_rmf_controls, get_rmf_evidence, @@ -63,6 +64,7 @@ send_rmf_chat_message, set_rmf_control_applicability, start_rmf_analysis, + start_rmf_collect, update_group_rmf_setup_status, update_group_rmf_state, validate_rmf_xlsx_package, @@ -1496,6 +1498,49 @@ def withdraw_rmf_workspace_evidence(import_id): return jsonify({"error": str(exc)}), exc.status_code return jsonify(result), 200 + @bp.route("/api/rmf/workspace/evidence/collect", methods=["POST"]) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + @enabled_required("enable_group_workspaces") + @enabled_required("enable_rmf") + def start_rmf_workspace_collect(): + user_id = get_current_user_id() + group_id, _, role, error_response = _rmf_group_context( + user_id, + allowed_roles=RMF_EVIDENCE_MANAGER_ROLES, + ) + if error_response: + return error_response + payload = request.get_json(silent=True) or {} + subscription_id = payload.get("subscription_id", "").strip() + if not subscription_id: + return jsonify({"error": "subscription_id is required"}), 400 + resource_group = payload.get("resource_group") or None + agentic = bool(payload.get("agentic", False)) + try: + result = start_rmf_collect(group_id, user_id, role, subscription_id, resource_group, agentic) + except RMFServiceError as exc: + return jsonify({"error": str(exc)}), exc.status_code + return jsonify(result), 202 + + @bp.route("/api/rmf/workspace/evidence/collect/jobs/", methods=["GET"]) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + @enabled_required("enable_group_workspaces") + @enabled_required("enable_rmf") + def get_rmf_workspace_collect_job(job_id): + user_id = get_current_user_id() + group_id, _, role, error_response = _rmf_group_context(user_id) + if error_response: + return error_response + try: + result = get_rmf_collect_job(group_id, user_id, role, job_id) + except RMFServiceError as exc: + return jsonify({"error": str(exc)}), exc.status_code + return jsonify(result), 200 + @bp.route("/api/rmf/workspace", methods=["PATCH"]) @swagger_route(security=get_auth_security()) @login_required diff --git a/application/single_app/static/js/rmf/rmf-collect.js b/application/single_app/static/js/rmf/rmf-collect.js new file mode 100644 index 000000000..7da9d49b0 --- /dev/null +++ b/application/single_app/static/js/rmf/rmf-collect.js @@ -0,0 +1,153 @@ +/** + * rmf-collect.js — Collect from Azure UI for the RMF evidence tab. + * + * Looks for [data-rmf-collect-form] in the DOM and wires up the collect flow: + * 1. User fills in subscription ID + optional resource group, toggles agentic + * 2. POST /api/rmf/workspace/evidence/collect → job_id + * 3. Poll GET .../collect/jobs/ every 3 s + * 4. Append log lines to the log panel; show final status + * 5. On success, dispatch "rmf:evidence-refresh" so the evidence table reloads + */ + +(function () { + "use strict"; + + const POLL_INTERVAL_MS = 3000; + const TERMINAL_STATUSES = new Set(["succeeded", "failed"]); + + function init() { + const form = document.querySelector("[data-rmf-collect-form]"); + if (!form) return; + + const submitBtn = form.querySelector("[data-rmf-collect-submit]"); + const subscriptionInput = form.querySelector("[data-rmf-collect-subscription]"); + const resourceGroupInput = form.querySelector("[data-rmf-collect-resource-group]"); + const agenticToggle = form.querySelector("[data-rmf-collect-agentic]"); + const statusPanel = form.querySelector("[data-rmf-collect-status]"); + const logPanel = form.querySelector("[data-rmf-collect-log]"); + const statusBadge = form.querySelector("[data-rmf-collect-status-badge]"); + + let pollTimer = null; + + function setRunning(running) { + submitBtn.disabled = running; + subscriptionInput.disabled = running; + if (resourceGroupInput) resourceGroupInput.disabled = running; + if (agenticToggle) agenticToggle.disabled = running; + submitBtn.textContent = running ? "Collecting…" : "Collect"; + } + + function showStatus(visible) { + statusPanel.hidden = !visible; + } + + function appendLog(lines) { + if (!logPanel) return; + lines.forEach(function (line) { + const el = document.createElement("div"); + el.textContent = line; + logPanel.appendChild(el); + logPanel.scrollTop = logPanel.scrollHeight; + }); + } + + function setBadge(status) { + if (!statusBadge) return; + const map = { + queued: ["bg-secondary", "Queued"], + running: ["bg-primary", "Running"], + succeeded: ["bg-success", "Succeeded"], + failed: ["bg-danger", "Failed"], + }; + const [cls, label] = map[status] || ["bg-secondary", status]; + statusBadge.className = "badge " + cls; + statusBadge.textContent = label; + } + + function poll(jobId, seenLogCount) { + fetch("/api/rmf/workspace/evidence/collect/jobs/" + encodeURIComponent(jobId)) + .then(function (r) { return r.json(); }) + .then(function (job) { + const log = job.log || []; + if (log.length > seenLogCount) { + appendLog(log.slice(seenLogCount)); + } + setBadge(job.status); + + if (TERMINAL_STATUSES.has(job.status)) { + clearTimeout(pollTimer); + setRunning(false); + if (job.status === "succeeded") { + document.dispatchEvent(new CustomEvent("rmf:evidence-refresh")); + } else if (job.error) { + appendLog(["Error: " + job.error]); + } + } else { + pollTimer = setTimeout(function () { + poll(jobId, log.length); + }, POLL_INTERVAL_MS); + } + }) + .catch(function (err) { + appendLog(["Poll error: " + err.message]); + setRunning(false); + }); + } + + form.addEventListener("submit", function (e) { + e.preventDefault(); + + const subscriptionId = (subscriptionInput.value || "").trim(); + if (!subscriptionId) { + subscriptionInput.focus(); + return; + } + + const resourceGroup = resourceGroupInput + ? (resourceGroupInput.value || "").trim() || null + : null; + const agentic = agenticToggle ? agenticToggle.checked : false; + + // Reset log + if (logPanel) logPanel.innerHTML = ""; + showStatus(true); + setBadge("queued"); + setRunning(true); + + fetch("/api/rmf/workspace/evidence/collect", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + subscription_id: subscriptionId, + resource_group: resourceGroup, + agentic: agentic, + }), + }) + .then(function (r) { + if (!r.ok) { + return r.json().then(function (body) { + throw new Error(body.error || r.statusText); + }); + } + return r.json(); + }) + .then(function (job) { + setBadge(job.status); + appendLog(["Job started: " + job.id]); + poll(job.id, 0); + }) + .catch(function (err) { + showStatus(true); + appendLog(["Failed to start collection: " + err.message]); + setBadge("failed"); + setRunning(false); + }); + }); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } +})(); diff --git a/application/single_app/templates/rmf_workspace.html b/application/single_app/templates/rmf_workspace.html index a6628f51f..d80bc78e4 100644 --- a/application/single_app/templates/rmf_workspace.html +++ b/application/single_app/templates/rmf_workspace.html @@ -319,11 +319,67 @@

Evidence

+ +
+
+

Collect from Azure

+

+ Enumerate Azure resources and import them as evidence directly from your subscription. + Static collection uses the Azure CLI; enable Agentic to also gather RBAC, diagnostics, + and Defender posture via Azure MCP (requires MCP credentials on the server). +

+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
@@ -3324,6 +3380,7 @@

Export history

"click", loadRmfEvidence ); + document.addEventListener("rmf:evidence-refresh", loadRmfEvidence); document.querySelectorAll('input[name="rmf-analysis-scope"]').forEach((input) => { input.addEventListener("change", () => { @@ -3654,4 +3711,5 @@

Export history

loadRmfServiceState(); + {% endblock %} From 143f1933b64516ba18b3399fde9f902e45dc1fdc Mon Sep 17 00:00:00 2001 From: Ed Clark Date: Fri, 31 Jul 2026 10:55:22 -0400 Subject: [PATCH 2/4] fix: pass allowed_roles to _rmf_group_context in collect job GET route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The GET /api/rmf/workspace/evidence/collect/jobs/ handler was calling _rmf_group_context(user_id) with one argument — the function requires two (user_id, allowed_roles). This raised a TypeError, which Flask returned as an HTML 500 page. The JS poll then failed with 'Unexpected token < ... is not valid JSON'. Fix: pass allowed_roles=RMF_EVIDENCE_MANAGER_ROLES (matching the POST route). Also harden the JS poll to check r.ok before r.json() so any future HTTP errors surface a readable status code instead of a parse error. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- application/single_app/route_backend_rmf.py | 5 ++++- application/single_app/static/js/rmf/rmf-collect.js | 9 ++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/application/single_app/route_backend_rmf.py b/application/single_app/route_backend_rmf.py index a389093cd..4471cc9d8 100644 --- a/application/single_app/route_backend_rmf.py +++ b/application/single_app/route_backend_rmf.py @@ -1532,7 +1532,10 @@ def start_rmf_workspace_collect(): @enabled_required("enable_rmf") def get_rmf_workspace_collect_job(job_id): user_id = get_current_user_id() - group_id, _, role, error_response = _rmf_group_context(user_id) + group_id, _, role, error_response = _rmf_group_context( + user_id, + allowed_roles=RMF_EVIDENCE_MANAGER_ROLES, + ) if error_response: return error_response try: diff --git a/application/single_app/static/js/rmf/rmf-collect.js b/application/single_app/static/js/rmf/rmf-collect.js index 7da9d49b0..1fe7afe99 100644 --- a/application/single_app/static/js/rmf/rmf-collect.js +++ b/application/single_app/static/js/rmf/rmf-collect.js @@ -66,7 +66,14 @@ function poll(jobId, seenLogCount) { fetch("/api/rmf/workspace/evidence/collect/jobs/" + encodeURIComponent(jobId)) - .then(function (r) { return r.json(); }) + .then(function (r) { + if (!r.ok) { + return r.text().then(function (t) { + throw new Error("HTTP " + r.status + ": " + t.substring(0, 200)); + }); + } + return r.json(); + }) .then(function (job) { const log = job.log || []; if (log.length > seenLogCount) { From 2510d2a47cd5fdb06f8231e58794bd6da77cc5cb Mon Sep 17 00:00:00 2001 From: Ed Clark Date: Fri, 31 Jul 2026 11:34:07 -0400 Subject: [PATCH 3/4] fix: restore rmf analysis tab pane id The evidence tab change accidentally dropped id="rmf-analysis" from the analysis section. Bootstrap uses that id as the tab target, so the analysis pane no longer activated even though the backend endpoints were healthy. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3402ff43-7857-401e-99f5-9a34dd704d0e --- application/single_app/templates/rmf_workspace.html | 1 + 1 file changed, 1 insertion(+) diff --git a/application/single_app/templates/rmf_workspace.html b/application/single_app/templates/rmf_workspace.html index d80bc78e4..5f29e4b52 100644 --- a/application/single_app/templates/rmf_workspace.html +++ b/application/single_app/templates/rmf_workspace.html @@ -380,6 +380,7 @@

Collect from Azure

From bc9e9696188b29bcaeedfcf7485f0c7eda4795b1 Mon Sep 17 00:00:00 2001 From: Ed Clark Date: Fri, 31 Jul 2026 12:35:14 -0400 Subject: [PATCH 4/4] =?UTF-8?q?feat(rmf):=20remove=20agentic=20toggle=20?= =?UTF-8?q?=E2=80=94=20always=20use=20MCP=20collect=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The static az-CLI path is gone; collect is always agentic. Drop the toggle from the UI and hardcode agentic:true in the POST body. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3402ff43-7857-401e-99f5-9a34dd704d0e --- application/single_app/static/js/rmf/rmf-collect.js | 7 ++----- application/single_app/templates/rmf_workspace.html | 10 ---------- 2 files changed, 2 insertions(+), 15 deletions(-) diff --git a/application/single_app/static/js/rmf/rmf-collect.js b/application/single_app/static/js/rmf/rmf-collect.js index 1fe7afe99..7388f57c0 100644 --- a/application/single_app/static/js/rmf/rmf-collect.js +++ b/application/single_app/static/js/rmf/rmf-collect.js @@ -2,7 +2,7 @@ * rmf-collect.js — Collect from Azure UI for the RMF evidence tab. * * Looks for [data-rmf-collect-form] in the DOM and wires up the collect flow: - * 1. User fills in subscription ID + optional resource group, toggles agentic + * 1. User fills in subscription ID + optional resource group * 2. POST /api/rmf/workspace/evidence/collect → job_id * 3. Poll GET .../collect/jobs/ every 3 s * 4. Append log lines to the log panel; show final status @@ -22,7 +22,6 @@ const submitBtn = form.querySelector("[data-rmf-collect-submit]"); const subscriptionInput = form.querySelector("[data-rmf-collect-subscription]"); const resourceGroupInput = form.querySelector("[data-rmf-collect-resource-group]"); - const agenticToggle = form.querySelector("[data-rmf-collect-agentic]"); const statusPanel = form.querySelector("[data-rmf-collect-status]"); const logPanel = form.querySelector("[data-rmf-collect-log]"); const statusBadge = form.querySelector("[data-rmf-collect-status-badge]"); @@ -33,7 +32,6 @@ submitBtn.disabled = running; subscriptionInput.disabled = running; if (resourceGroupInput) resourceGroupInput.disabled = running; - if (agenticToggle) agenticToggle.disabled = running; submitBtn.textContent = running ? "Collecting…" : "Collect"; } @@ -113,7 +111,6 @@ const resourceGroup = resourceGroupInput ? (resourceGroupInput.value || "").trim() || null : null; - const agentic = agenticToggle ? agenticToggle.checked : false; // Reset log if (logPanel) logPanel.innerHTML = ""; @@ -127,7 +124,7 @@ body: JSON.stringify({ subscription_id: subscriptionId, resource_group: resourceGroup, - agentic: agentic, + agentic: true, }), }) .then(function (r) { diff --git a/application/single_app/templates/rmf_workspace.html b/application/single_app/templates/rmf_workspace.html index 5f29e4b52..2e1586028 100644 --- a/application/single_app/templates/rmf_workspace.html +++ b/application/single_app/templates/rmf_workspace.html @@ -350,16 +350,6 @@

Collect from Azure

placeholder="rg-my-workload" autocomplete="off" spellcheck="false"> -
-
- - -
-