From 7a599cdef7b0c881187ff49db9b82dda506ce119 Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Sat, 15 Nov 2025 05:33:46 -0700 Subject: [PATCH] Locked-down endpoints: /api/agents, /api/devices, /api/devices/, /api/device/details/, /api/device/description/, /api/device_list_views, /api/device_list_views/, /api/sites, /api/sites/delete, /api/sites/device_map, /api/sites/assign, /api/sites/rename, /api/repo/current_hash, /api/agent/hash_list, /api/scripts/quick_run, /api/ansible/quick_run, /api/device/activity/, /api/device/activity/job/, /api/server/time. --- .../services/API/assemblies/execution.py | 26 +++++++- .../Engine/services/API/devices/management.py | 59 ++++++++++++++++--- Data/Engine/services/API/server/info.py | 13 +++- readme.md | 1 + 4 files changed, 86 insertions(+), 13 deletions(-) diff --git a/Data/Engine/services/API/assemblies/execution.py b/Data/Engine/services/API/assemblies/execution.py index d11a735d..9e44c2f3 100644 --- a/Data/Engine/services/API/assemblies/execution.py +++ b/Data/Engine/services/API/assemblies/execution.py @@ -28,6 +28,7 @@ if TYPE_CHECKING: # pragma: no cover - typing aide from .. import EngineServiceAdapters from ...assemblies.service import AssemblyRuntimeService +from ...auth import RequestAuthContext def _assemblies_root() -> Path: @@ -404,9 +405,18 @@ def register_execution(app: "Flask", adapters: "EngineServiceAdapters") -> None: if assembly_cache is None: raise RuntimeError("Assembly cache is not initialised; ensure Engine bootstrap executed.") assembly_runtime = AssemblyRuntimeService(assembly_cache, logger=adapters.context.logger) + auth = RequestAuthContext( + app=app, + dev_mode_manager=adapters.dev_mode_manager, + config=adapters.config, + logger=adapters.context.logger, + ) @blueprint.route("/api/scripts/quick_run", methods=["POST"]) def scripts_quick_run(): + user, error = auth.require_user() + if error: + return jsonify(error[0]), error[1] data = request.get_json(silent=True) or {} rel_path_input = data.get("script_path") rel_path_normalized = _normalize_script_relpath(rel_path_input) @@ -419,6 +429,7 @@ def register_execution(app: "Flask", adapters: "EngineServiceAdapters") -> None: return jsonify({"error": "Missing script_path or hostnames[]"}), 400 rel_path_canonical = rel_path_normalized + username = (user.get("username") if isinstance(user, dict) else None) or "unknown" assembly_source = "runtime" assembly_guid: Optional[str] = None @@ -455,7 +466,7 @@ def register_execution(app: "Flask", adapters: "EngineServiceAdapters") -> None: except Exception as exc: # pragma: no cover - defensive guard service_log( "assemblies", - f"quick job failed to resolve script path={rel_path_input!r}: {exc}", + f"quick job failed to resolve script path={rel_path_input!r} user={username}: {exc}", level="ERROR", ) return jsonify({"error": "Failed to resolve script path"}), 500 @@ -470,7 +481,7 @@ def register_execution(app: "Flask", adapters: "EngineServiceAdapters") -> None: if not within_scripts or not os.path.isfile(abs_path_str): service_log( "assemblies", - f"quick job requested missing or out-of-scope script input={rel_path_input!r} normalized={rel_path_canonical}", + f"quick job requested missing or out-of-scope script input={rel_path_input!r} normalized={rel_path_canonical} user={username}", level="WARNING", ) return jsonify({"error": "Script not found"}), 404 @@ -597,7 +608,7 @@ def register_execution(app: "Flask", adapters: "EngineServiceAdapters") -> None: results.append({"hostname": host, "job_id": job_id, "status": "Running"}) service_log( "assemblies", - f"quick job queued hostname={host} path={rel_path_canonical} run_mode={run_mode} source={assembly_source}", + f"quick job queued hostname={host} path={rel_path_canonical} run_mode={run_mode} source={assembly_source} requested_by={username}", ) except Exception as exc: if conn is not None: @@ -611,10 +622,16 @@ def register_execution(app: "Flask", adapters: "EngineServiceAdapters") -> None: @blueprint.route("/api/ansible/quick_run", methods=["POST"]) def ansible_quick_run(): + _, error = auth.require_user() + if error: + return jsonify(error[0]), error[1] return jsonify({"error": "Ansible quick run is not yet available in the Engine runtime."}), 501 @blueprint.route("/api/device/activity/", methods=["GET", "DELETE"]) def device_activity(hostname: str): + _, error = auth.require_user() + if error: + return jsonify(error[0]), error[1] conn = None try: conn = adapters.db_conn_factory() @@ -657,6 +674,9 @@ def register_execution(app: "Flask", adapters: "EngineServiceAdapters") -> None: @blueprint.route("/api/device/activity/job/", methods=["GET"]) def device_activity_job(job_id: int): + _, error = auth.require_user() + if error: + return jsonify(error[0]), error[1] conn = None try: conn = adapters.db_conn_factory() diff --git a/Data/Engine/services/API/devices/management.py b/Data/Engine/services/API/devices/management.py index 2b213d9e..db83c78a 100644 --- a/Data/Engine/services/API/devices/management.py +++ b/Data/Engine/services/API/devices/management.py @@ -4,24 +4,25 @@ # # API Endpoints (if applicable): # - POST /api/agent/details (Device Authenticated) - Ingests hardware and inventory payloads from enrolled agents. -# - GET /api/devices (No Authentication) - Returns a summary list of known devices for the WebUI transition. -# - GET /api/devices/ (No Authentication) - Retrieves a single device record by GUID, including summary fields. -# - GET /api/device/details/ (No Authentication) - Returns full device details keyed by hostname. +# - GET /api/agents (Token Authenticated) - Lists online collectors grouped by hostname and run context. +# - GET /api/devices (Token Authenticated) - Returns a summary list of known devices for the WebUI transition. +# - GET /api/devices/ (Token Authenticated) - Retrieves a single device record by GUID, including summary fields. +# - GET /api/device/details/ (Token Authenticated) - Returns full device details keyed by hostname. # - POST /api/device/description/ (Token Authenticated) - Updates the human-readable description for a device. -# - GET /api/device_list_views (No Authentication) - Lists saved device table view definitions. -# - GET /api/device_list_views/ (No Authentication) - Retrieves a specific saved device table view definition. +# - GET /api/device_list_views (Token Authenticated) - Lists saved device table view definitions. +# - GET /api/device_list_views/ (Token Authenticated) - Retrieves a specific saved device table view definition. # - POST /api/device_list_views (Token Authenticated) - Creates a custom device list view for the signed-in operator. # - PUT /api/device_list_views/ (Token Authenticated) - Updates an existing device list view definition. # - DELETE /api/device_list_views/ (Token Authenticated) - Deletes a saved device list view. -# - GET /api/sites (No Authentication) - Lists known sites and their summary metadata. +# - GET /api/sites (Token Authenticated) - Lists known sites and their summary metadata. # - POST /api/sites (Token Authenticated (Admin)) - Creates a new site for grouping devices. # - POST /api/sites/delete (Token Authenticated (Admin)) - Deletes one or more sites by identifier. -# - GET /api/sites/device_map (No Authentication) - Provides hostname to site assignment mapping data. +# - GET /api/sites/device_map (Token Authenticated) - Provides hostname to site assignment mapping data. # - POST /api/sites/assign (Token Authenticated (Admin)) - Assigns a set of devices to a given site. # - POST /api/sites/rename (Token Authenticated (Admin)) - Renames an existing site record. -# - GET /api/repo/current_hash (No Authentication) - Fetches the current agent repository hash (with caching). +# - GET /api/repo/current_hash (Token Authenticated) - Fetches the current agent repository hash (with caching). # - GET/POST /api/agent/hash (Device Authenticated) - Retrieves or updates an agent hash record bound to the authenticated device. -# - GET /api/agent/hash_list (Loopback Restricted) - Returns stored agent hash metadata for localhost diagnostics. +# - GET /api/agent/hash_list (Token Authenticated (Admin + Loopback)) - Returns stored agent hash metadata for localhost diagnostics. # ====================================================== """Device management endpoints for the Borealis Engine API.""" @@ -1584,21 +1585,37 @@ def register_management(app, adapters: "EngineServiceAdapters") -> None: @blueprint.route("/api/agents", methods=["GET"]) def _list_agents(): + requirement = service._require_login() + if requirement: + payload, status = requirement + return jsonify(payload), status payload, status = service.list_agents() return jsonify(payload), status @blueprint.route("/api/devices", methods=["GET"]) def _list_devices(): + requirement = service._require_login() + if requirement: + payload, status = requirement + return jsonify(payload), status payload, status = service.list_devices() return jsonify(payload), status @blueprint.route("/api/devices/", methods=["GET"]) def _device_by_guid(guid: str): + requirement = service._require_login() + if requirement: + payload, status = requirement + return jsonify(payload), status payload, status = service.get_device_by_guid(guid) return jsonify(payload), status @blueprint.route("/api/device/details/", methods=["GET"]) def _device_details(hostname: str): + requirement = service._require_login() + if requirement: + payload, status = requirement + return jsonify(payload), status payload, status = service.get_device_details(hostname) return jsonify(payload), status @@ -1615,11 +1632,19 @@ def register_management(app, adapters: "EngineServiceAdapters") -> None: @blueprint.route("/api/device_list_views", methods=["GET"]) def _list_views(): + requirement = service._require_login() + if requirement: + payload, status = requirement + return jsonify(payload), status payload, status = service.list_views() return jsonify(payload), status @blueprint.route("/api/device_list_views/", methods=["GET"]) def _get_view(view_id: int): + requirement = service._require_login() + if requirement: + payload, status = requirement + return jsonify(payload), status payload, status = service.get_view(view_id) return jsonify(payload), status @@ -1679,6 +1704,10 @@ def register_management(app, adapters: "EngineServiceAdapters") -> None: @blueprint.route("/api/sites", methods=["GET"]) def _sites_list(): + requirement = service._require_login() + if requirement: + payload, status = requirement + return jsonify(payload), status payload, status = service.list_sites() return jsonify(payload), status @@ -1707,6 +1736,10 @@ def register_management(app, adapters: "EngineServiceAdapters") -> None: @blueprint.route("/api/sites/device_map", methods=["GET"]) def _sites_device_map(): + requirement = service._require_login() + if requirement: + payload, status = requirement + return jsonify(payload), status payload, status = service.sites_device_map(request.args.get("hostnames")) return jsonify(payload), status @@ -1732,11 +1765,19 @@ def register_management(app, adapters: "EngineServiceAdapters") -> None: @blueprint.route("/api/repo/current_hash", methods=["GET"]) def _repo_current_hash(): + requirement = service._require_login() + if requirement: + payload, status = requirement + return jsonify(payload), status payload, status = service.repo_current_hash() return jsonify(payload), status @blueprint.route("/api/agent/hash_list", methods=["GET"]) def _agent_hash_list(): + requirement = service._require_admin() + if requirement: + payload, status = requirement + return jsonify(payload), status payload, status = service.agent_hash_list() return jsonify(payload), status diff --git a/Data/Engine/services/API/server/info.py b/Data/Engine/services/API/server/info.py index 187b1a7d..1831eb98 100644 --- a/Data/Engine/services/API/server/info.py +++ b/Data/Engine/services/API/server/info.py @@ -13,6 +13,8 @@ from typing import TYPE_CHECKING, Any, Dict from flask import Blueprint, Flask, jsonify +from ...auth import RequestAuthContext + if TYPE_CHECKING: # pragma: no cover - typing aide from .. import EngineServiceAdapters @@ -31,13 +33,22 @@ def _serialize_time(now_local: datetime, now_utc: datetime) -> Dict[str, Any]: } -def register_info(app: Flask, _adapters: "EngineServiceAdapters") -> None: +def register_info(app: Flask, adapters: "EngineServiceAdapters") -> None: """Expose server telemetry endpoints used by the admin interface.""" blueprint = Blueprint("engine_server_info", __name__) + auth = RequestAuthContext( + app=app, + dev_mode_manager=adapters.dev_mode_manager, + config=adapters.config, + logger=adapters.context.logger, + ) @blueprint.route("/api/server/time", methods=["GET"]) def server_time() -> Any: + _, error = auth.require_user() + if error: + return jsonify(error[0]), error[1] now_utc = datetime.now(timezone.utc) now_local = now_utc.astimezone() payload = _serialize_time(now_local, now_utc) diff --git a/readme.md b/readme.md index ad11b352..d0ca1f75 100644 --- a/readme.md +++ b/readme.md @@ -111,6 +111,7 @@ The process that agents go through when authenticating securely with a Borealis - All device APIs now require Authorization: Bearer headers and a service-context (e.g. SYSTEM or CURRENTUSER) marker; missing, expired, mismatched, or revoked credentials are rejected before any business logic runs. Operator-driven revoking / device quarantining logic is not yet implemented. - Replay and credential theft defenses layer in DPoP proof validation (thumbprint binding) on the server side and short-lived access tokens (15 min) with 30-day refresh tokens hashed via SHA-256. - Centralized logging under Logs/Server and Agent/Logs captures enrollment approvals, rate-limit hits, signature failures, and auth anomalies for post-incident review. +- The Engine’s operator-facing API endpoints (device inventory, assemblies, job history, etc.) require an authenticated operator session or bearer token; unauthenticated requests are rejected with 401/403 responses before any inventory or script metadata is returned and the requesting user is logged with each quick-run dispatch. #### Server Security - Auto-manages PKI: a persistent Borealis root CA (ECDSA SECP384R1) signs leaf certificates that include localhost SANs, tightened filesystem permissions, and a combined bundle for agent identity / cert pinning. - Script delivery is code-signed with an Ed25519 key stored under Certificates/Server/Code-Signing; agents refuse any payload whose signature or hash does not match the pinned public key.