Locked-down endpoints: /api/agents, /api/devices, /api/devices/<guid>, /api/device/details/<hostname>, /api/device/description/<hostname>, /api/device_list_views, /api/device_list_views/<view_id>, /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/<hostname>, /api/device/activity/job/<job_id>, /api/server/time.

This commit is contained in:
2025-11-15 05:33:46 -07:00
parent b44aff64a3
commit 7a599cdef7
4 changed files with 86 additions and 13 deletions

View File

@@ -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/<hostname>", 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/<int:job_id>", 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()

View File

@@ -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/<guid> (No Authentication) - Retrieves a single device record by GUID, including summary fields.
# - GET /api/device/details/<hostname> (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/<guid> (Token Authenticated) - Retrieves a single device record by GUID, including summary fields.
# - GET /api/device/details/<hostname> (Token Authenticated) - Returns full device details keyed by hostname.
# - POST /api/device/description/<hostname> (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/<int:view_id> (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/<int:view_id> (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/<int:view_id> (Token Authenticated) - Updates an existing device list view definition.
# - DELETE /api/device_list_views/<int:view_id> (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/<guid>", 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/<hostname>", 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/<int:view_id>", 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

View File

@@ -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)

View File

@@ -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 (15min) 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 Engines 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.