mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-12-15 18:55:48 -07:00
Assembly Management Rework - Stage 3 Complete
This commit is contained in:
@@ -19,14 +19,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple
|
||||
|
||||
from flask import Blueprint, jsonify, request, session
|
||||
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from ....assembly_management.models import AssemblyDomain
|
||||
from ...assemblies.service import AssemblyRuntimeService
|
||||
from ...auth import RequestAuthContext
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - typing aide
|
||||
from .. import EngineServiceAdapters
|
||||
@@ -43,81 +42,104 @@ class AssemblyAPIService:
|
||||
if cache is None:
|
||||
raise RuntimeError("Assembly cache not initialised; ensure Engine bootstrap executed.")
|
||||
self.runtime = AssemblyRuntimeService(cache, logger=self.logger)
|
||||
self.service_log = adapters.service_log
|
||||
self.auth = RequestAuthContext(
|
||||
app=app,
|
||||
dev_mode_manager=adapters.dev_mode_manager,
|
||||
config=adapters.config,
|
||||
logger=self.logger,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Authentication helpers
|
||||
# ------------------------------------------------------------------
|
||||
def require_user(self) -> Tuple[Optional[Dict[str, Any]], Optional[Tuple[Dict[str, Any], int]]]:
|
||||
user = self._current_user()
|
||||
if not user:
|
||||
return None, ({"error": "unauthorized"}, 401)
|
||||
return user, None
|
||||
return self.auth.require_user()
|
||||
|
||||
def require_admin(self, *, dev_mode_required: bool = False) -> Optional[Tuple[Dict[str, Any], int]]:
|
||||
user = self._current_user()
|
||||
if not user:
|
||||
return {"error": "unauthorized"}, 401
|
||||
if not self._is_admin(user):
|
||||
return {"error": "admin required"}, 403
|
||||
if dev_mode_required and not self._dev_mode_enabled():
|
||||
return {"error": "dev mode required"}, 403
|
||||
return None
|
||||
def require_admin(
|
||||
self,
|
||||
*,
|
||||
dev_mode_required: bool = False,
|
||||
user: Optional[Dict[str, Any]] = None,
|
||||
) -> Tuple[Optional[Dict[str, Any]], Optional[Tuple[Dict[str, Any], int]]]:
|
||||
actor = user
|
||||
if actor is None:
|
||||
actor, error = self.require_user()
|
||||
if error:
|
||||
detail = error[0].get("message") or error[0].get("error") or "authentication required"
|
||||
self._audit(user=None, action="admin_check", status="denied", detail=detail)
|
||||
return actor, error
|
||||
|
||||
def require_mutation_for_domain(self, domain: AssemblyDomain) -> Optional[Tuple[Dict[str, Any], int]]:
|
||||
if not RequestAuthContext.is_admin(actor):
|
||||
payload = {
|
||||
"error": "forbidden",
|
||||
"message": "Administrator permissions are required for this action.",
|
||||
}
|
||||
self._audit(user=actor, action="admin_check", status="denied", detail=payload["message"])
|
||||
return actor, (payload, 403)
|
||||
|
||||
if dev_mode_required and not self.auth.dev_mode_enabled(user=actor):
|
||||
payload = {
|
||||
"error": "dev_mode_required",
|
||||
"message": "Enable Dev Mode from the Assemblies admin controls to continue.",
|
||||
}
|
||||
self._audit(user=actor, action="dev_mode_check", status="denied", detail=payload["message"])
|
||||
return actor, (payload, 403)
|
||||
|
||||
return actor, None
|
||||
|
||||
def require_mutation_for_domain(
|
||||
self,
|
||||
domain: AssemblyDomain,
|
||||
) -> Tuple[Optional[Dict[str, Any]], Optional[Tuple[Dict[str, Any], int]]]:
|
||||
user, error = self.require_user()
|
||||
if error:
|
||||
return error
|
||||
detail = error[0].get("message") or error[0].get("error") or "authentication required"
|
||||
self._audit(user=None, action="mutation_check", domain=domain, status="denied", detail=detail)
|
||||
return user, error
|
||||
if domain == AssemblyDomain.USER:
|
||||
return None
|
||||
if not self._is_admin(user):
|
||||
return {"error": "admin required for non-user domains"}, 403
|
||||
if not self._dev_mode_enabled():
|
||||
return {"error": "dev mode required for privileged domains"}, 403
|
||||
return None
|
||||
return user, None
|
||||
_, admin_error = self.require_admin(dev_mode_required=True, user=user)
|
||||
if admin_error:
|
||||
return user, admin_error
|
||||
return user, None
|
||||
|
||||
def _token_serializer(self) -> URLSafeTimedSerializer:
|
||||
secret = self.app.secret_key or "borealis-dev-secret"
|
||||
return URLSafeTimedSerializer(secret, salt="borealis-auth")
|
||||
|
||||
def _current_user(self) -> Optional[Dict[str, Any]]:
|
||||
username = session.get("username")
|
||||
role = session.get("role") or "User"
|
||||
if username:
|
||||
return {"username": username, "role": role}
|
||||
|
||||
token = self._bearer_token()
|
||||
if not token:
|
||||
return None
|
||||
max_age = int(os.environ.get("BOREALIS_TOKEN_TTL_SECONDS", 60 * 60 * 24 * 30))
|
||||
# ------------------------------------------------------------------
|
||||
# Audit helpers
|
||||
# ------------------------------------------------------------------
|
||||
def _audit(
|
||||
self,
|
||||
*,
|
||||
user: Optional[Dict[str, Any]],
|
||||
action: str,
|
||||
domain: Optional[AssemblyDomain] = None,
|
||||
assembly_guid: Optional[str] = None,
|
||||
status: str = "success",
|
||||
detail: Optional[str] = None,
|
||||
) -> None:
|
||||
actor = user or {}
|
||||
username = (actor.get("username") or "").strip() or "anonymous"
|
||||
role = (actor.get("role") or "").strip() or "unknown"
|
||||
domain_value = domain.value if isinstance(domain, AssemblyDomain) else (domain or "n/a")
|
||||
dev_mode_flag = self.auth.dev_mode_enabled(user=user) if user else False
|
||||
parts = [
|
||||
f"user={username}",
|
||||
f"role={role}",
|
||||
f"action={action}",
|
||||
f"domain={domain_value}",
|
||||
f"status={status}",
|
||||
f"dev_mode={'true' if dev_mode_flag else 'false'}",
|
||||
]
|
||||
if assembly_guid:
|
||||
parts.append(f"assembly={assembly_guid}")
|
||||
if detail:
|
||||
parts.append(f"detail={detail}")
|
||||
message = " ".join(parts)
|
||||
self.logger.info("Assemblies audit - %s", message)
|
||||
try:
|
||||
data = self._token_serializer().loads(token, max_age=max_age)
|
||||
username = data.get("u")
|
||||
role = data.get("r") or "User"
|
||||
if username:
|
||||
return {"username": username, "role": role}
|
||||
except (BadSignature, SignatureExpired, Exception):
|
||||
return None
|
||||
return None
|
||||
|
||||
def _bearer_token(self) -> Optional[str]:
|
||||
auth_header = request.headers.get("Authorization") or ""
|
||||
if auth_header.lower().startswith("bearer "):
|
||||
return auth_header.split(" ", 1)[1].strip()
|
||||
cookie_token = request.cookies.get("borealis_auth")
|
||||
if cookie_token:
|
||||
return cookie_token
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _is_admin(user: Dict[str, Any]) -> bool:
|
||||
role = (user.get("role") or "").strip().lower()
|
||||
return role == "admin"
|
||||
|
||||
def _dev_mode_enabled(self) -> bool:
|
||||
return bool(session.get("assemblies_dev_mode", False))
|
||||
|
||||
def set_dev_mode(self, enabled: bool) -> None:
|
||||
session["assemblies_dev_mode"] = bool(enabled)
|
||||
self.service_log("assemblies", message, scope="ADMIN")
|
||||
except Exception: # pragma: no cover - logging safeguard
|
||||
self.logger.debug("Failed to write assemblies service log entry.", exc_info=True)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Domain helpers
|
||||
@@ -177,16 +199,43 @@ def register_assemblies(app, adapters: "EngineServiceAdapters") -> None:
|
||||
domain = service.parse_domain(payload.get("domain"))
|
||||
if domain is None:
|
||||
return jsonify({"error": "invalid domain"}), 400
|
||||
error = service.require_mutation_for_domain(domain)
|
||||
user, error = service.require_mutation_for_domain(domain)
|
||||
pending_guid = str(payload.get("assembly_guid") or "").strip() or None
|
||||
if error:
|
||||
detail = error[0].get("message") or error[0].get("error") or "permission denied"
|
||||
service._audit(user=user, action="create", domain=domain, assembly_guid=pending_guid, status="denied", detail=detail)
|
||||
return jsonify(error[0]), error[1]
|
||||
try:
|
||||
record = service.runtime.create_assembly(payload)
|
||||
service._audit(
|
||||
user=user,
|
||||
action="create",
|
||||
domain=domain,
|
||||
assembly_guid=record.get("assembly_guid"),
|
||||
status="success",
|
||||
detail="queued",
|
||||
)
|
||||
return jsonify(record), 201
|
||||
except ValueError as exc:
|
||||
service._audit(
|
||||
user=user,
|
||||
action="create",
|
||||
domain=domain,
|
||||
assembly_guid=pending_guid,
|
||||
status="failed",
|
||||
detail=str(exc),
|
||||
)
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
except Exception: # pragma: no cover - runtime guard
|
||||
service.logger.exception("Failed to create assembly.")
|
||||
service._audit(
|
||||
user=user,
|
||||
action="create",
|
||||
domain=domain,
|
||||
assembly_guid=pending_guid,
|
||||
status="error",
|
||||
detail="internal server error",
|
||||
)
|
||||
return jsonify({"error": "internal server error"}), 500
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -198,16 +247,49 @@ def register_assemblies(app, adapters: "EngineServiceAdapters") -> None:
|
||||
existing = service.runtime.get_cached_entry(assembly_guid)
|
||||
if not existing:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
error = service.require_mutation_for_domain(existing.domain)
|
||||
user, error = service.require_mutation_for_domain(existing.domain)
|
||||
if error:
|
||||
detail = error[0].get("message") or error[0].get("error") or "permission denied"
|
||||
service._audit(
|
||||
user=user,
|
||||
action="update",
|
||||
domain=existing.domain,
|
||||
assembly_guid=assembly_guid,
|
||||
status="denied",
|
||||
detail=detail,
|
||||
)
|
||||
return jsonify(error[0]), error[1]
|
||||
try:
|
||||
record = service.runtime.update_assembly(assembly_guid, payload)
|
||||
service._audit(
|
||||
user=user,
|
||||
action="update",
|
||||
domain=existing.domain,
|
||||
assembly_guid=assembly_guid,
|
||||
status="success",
|
||||
detail="queued",
|
||||
)
|
||||
return jsonify(record), 200
|
||||
except ValueError as exc:
|
||||
service._audit(
|
||||
user=user,
|
||||
action="update",
|
||||
domain=existing.domain,
|
||||
assembly_guid=assembly_guid,
|
||||
status="failed",
|
||||
detail=str(exc),
|
||||
)
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
except Exception: # pragma: no cover - runtime guard
|
||||
service.logger.exception("Failed to update assembly %s.", assembly_id)
|
||||
service.logger.exception("Failed to update assembly %s.", assembly_guid)
|
||||
service._audit(
|
||||
user=user,
|
||||
action="update",
|
||||
domain=existing.domain,
|
||||
assembly_guid=assembly_guid,
|
||||
status="error",
|
||||
detail="internal server error",
|
||||
)
|
||||
return jsonify({"error": "internal server error"}), 500
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -218,16 +300,49 @@ def register_assemblies(app, adapters: "EngineServiceAdapters") -> None:
|
||||
existing = service.runtime.get_cached_entry(assembly_guid)
|
||||
if not existing:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
error = service.require_mutation_for_domain(existing.domain)
|
||||
user, error = service.require_mutation_for_domain(existing.domain)
|
||||
if error:
|
||||
detail = error[0].get("message") or error[0].get("error") or "permission denied"
|
||||
service._audit(
|
||||
user=user,
|
||||
action="delete",
|
||||
domain=existing.domain,
|
||||
assembly_guid=assembly_guid,
|
||||
status="denied",
|
||||
detail=detail,
|
||||
)
|
||||
return jsonify(error[0]), error[1]
|
||||
try:
|
||||
service.runtime.delete_assembly(assembly_guid)
|
||||
service._audit(
|
||||
user=user,
|
||||
action="delete",
|
||||
domain=existing.domain,
|
||||
assembly_guid=assembly_guid,
|
||||
status="success",
|
||||
detail="queued",
|
||||
)
|
||||
return jsonify({"status": "queued"}), 202
|
||||
except ValueError as exc:
|
||||
service._audit(
|
||||
user=user,
|
||||
action="delete",
|
||||
domain=existing.domain,
|
||||
assembly_guid=assembly_guid,
|
||||
status="failed",
|
||||
detail=str(exc),
|
||||
)
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
except Exception: # pragma: no cover
|
||||
service.logger.exception("Failed to delete assembly %s.", assembly_id)
|
||||
service.logger.exception("Failed to delete assembly %s.", assembly_guid)
|
||||
service._audit(
|
||||
user=user,
|
||||
action="delete",
|
||||
domain=existing.domain,
|
||||
assembly_guid=assembly_guid,
|
||||
status="error",
|
||||
detail="internal server error",
|
||||
)
|
||||
return jsonify({"error": "internal server error"}), 500
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -240,8 +355,18 @@ def register_assemblies(app, adapters: "EngineServiceAdapters") -> None:
|
||||
domain = service.parse_domain(target_domain_value)
|
||||
if domain is None:
|
||||
return jsonify({"error": "invalid target domain"}), 400
|
||||
error = service.require_mutation_for_domain(domain)
|
||||
user, error = service.require_mutation_for_domain(domain)
|
||||
pending_guid = str(payload.get("new_assembly_guid") or "").strip() or None
|
||||
if error:
|
||||
detail = error[0].get("message") or error[0].get("error") or "permission denied"
|
||||
service._audit(
|
||||
user=user,
|
||||
action="clone",
|
||||
domain=domain,
|
||||
assembly_guid=pending_guid or assembly_guid,
|
||||
status="denied",
|
||||
detail=detail,
|
||||
)
|
||||
return jsonify(error[0]), error[1]
|
||||
new_guid = payload.get("new_assembly_guid")
|
||||
try:
|
||||
@@ -250,11 +375,35 @@ def register_assemblies(app, adapters: "EngineServiceAdapters") -> None:
|
||||
target_domain=domain.value,
|
||||
new_assembly_guid=new_guid,
|
||||
)
|
||||
service._audit(
|
||||
user=user,
|
||||
action="clone",
|
||||
domain=domain,
|
||||
assembly_guid=record.get("assembly_guid"),
|
||||
status="success",
|
||||
detail=f"source={assembly_guid}",
|
||||
)
|
||||
return jsonify(record), 201
|
||||
except ValueError as exc:
|
||||
service._audit(
|
||||
user=user,
|
||||
action="clone",
|
||||
domain=domain,
|
||||
assembly_guid=pending_guid or assembly_guid,
|
||||
status="failed",
|
||||
detail=str(exc),
|
||||
)
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
except Exception: # pragma: no cover
|
||||
service.logger.exception("Failed to clone assembly %s.", assembly_id)
|
||||
service.logger.exception("Failed to clone assembly %s.", assembly_guid)
|
||||
service._audit(
|
||||
user=user,
|
||||
action="clone",
|
||||
domain=domain,
|
||||
assembly_guid=pending_guid or assembly_guid,
|
||||
status="error",
|
||||
detail="internal server error",
|
||||
)
|
||||
return jsonify({"error": "internal server error"}), 500
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -262,27 +411,35 @@ def register_assemblies(app, adapters: "EngineServiceAdapters") -> None:
|
||||
# ------------------------------------------------------------------
|
||||
@blueprint.route("/dev-mode/switch", methods=["POST"])
|
||||
def switch_dev_mode():
|
||||
error = service.require_admin()
|
||||
user, error = service.require_admin()
|
||||
if error:
|
||||
return jsonify(error[0]), error[1]
|
||||
payload = request.get_json(silent=True) or {}
|
||||
enabled = bool(payload.get("enabled"))
|
||||
service.set_dev_mode(enabled)
|
||||
return jsonify({"dev_mode": service._dev_mode_enabled()}), 200
|
||||
try:
|
||||
final_state = service.auth.set_dev_mode(enabled)
|
||||
except PermissionError: # pragma: no cover - defensive
|
||||
service._audit(user=user, action="dev_mode_toggle", status="error", detail="state persistence failure")
|
||||
return jsonify({"error": "unable to update dev mode state"}), 500
|
||||
detail = "enabled" if final_state else "disabled"
|
||||
service._audit(user=user, action="dev_mode_toggle", status="success", detail=detail)
|
||||
return jsonify({"dev_mode": final_state}), 200
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Immediate flush
|
||||
# ------------------------------------------------------------------
|
||||
@blueprint.route("/dev-mode/write", methods=["POST"])
|
||||
def flush_assemblies():
|
||||
error = service.require_admin(dev_mode_required=True)
|
||||
user, error = service.require_admin(dev_mode_required=True)
|
||||
if error:
|
||||
return jsonify(error[0]), error[1]
|
||||
try:
|
||||
service.runtime.flush_writes()
|
||||
service._audit(user=user, action="flush_queue", status="success", detail="manual flush")
|
||||
return jsonify({"status": "flushed"}), 200
|
||||
except Exception: # pragma: no cover
|
||||
service.logger.exception("Failed to flush assembly queue.")
|
||||
service._audit(user=user, action="flush_queue", status="error", detail="internal server error")
|
||||
return jsonify({"error": "internal server error"}), 500
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -290,14 +447,16 @@ def register_assemblies(app, adapters: "EngineServiceAdapters") -> None:
|
||||
# ------------------------------------------------------------------
|
||||
@blueprint.route("/official/sync", methods=["POST"])
|
||||
def sync_official():
|
||||
error = service.require_admin(dev_mode_required=True)
|
||||
user, error = service.require_admin(dev_mode_required=True)
|
||||
if error:
|
||||
return jsonify(error[0]), error[1]
|
||||
try:
|
||||
service.runtime.sync_official()
|
||||
service._audit(user=user, action="sync_official", status="success")
|
||||
return jsonify({"status": "synced"}), 200
|
||||
except Exception: # pragma: no cover
|
||||
service.logger.exception("Official assembly sync failed.")
|
||||
service._audit(user=user, action="sync_official", status="error", detail="internal server error")
|
||||
return jsonify({"error": "internal server error"}), 500
|
||||
|
||||
app.register_blueprint(blueprint)
|
||||
|
||||
Reference in New Issue
Block a user