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..4471cc9d8 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,52 @@ 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, + allowed_roles=RMF_EVIDENCE_MANAGER_ROLES, + ) + 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..7388f57c0 --- /dev/null +++ b/application/single_app/static/js/rmf/rmf-collect.js @@ -0,0 +1,157 @@ +/** + * 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 + * 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 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; + 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) { + 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) { + 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; + + // 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: true, + }), + }) + .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..2e1586028 100644 --- a/application/single_app/templates/rmf_workspace.html +++ b/application/single_app/templates/rmf_workspace.html @@ -319,6 +319,53 @@

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). +

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

Export history

loadRmfServiceState(); + {% endblock %}