Add assembly endpoints and approval flows

This commit is contained in:
2025-10-23 00:31:48 -06:00
parent 4bc529aaf4
commit 82210408ca
11 changed files with 1510 additions and 1 deletions

View File

@@ -28,6 +28,10 @@ __all__ = [
"DeviceInventoryService",
"DeviceViewService",
"CredentialService",
"AssemblyService",
"AssemblyListing",
"AssemblyLoadResult",
"AssemblyMutationResult",
]
_LAZY_TARGETS: Dict[str, Tuple[str, str]] = {
@@ -65,6 +69,22 @@ _LAZY_TARGETS: Dict[str, Tuple[str, str]] = {
"Data.Engine.services.credentials.credential_service",
"CredentialService",
),
"AssemblyService": (
"Data.Engine.services.assemblies.assembly_service",
"AssemblyService",
),
"AssemblyListing": (
"Data.Engine.services.assemblies.assembly_service",
"AssemblyListing",
),
"AssemblyLoadResult": (
"Data.Engine.services.assemblies.assembly_service",
"AssemblyLoadResult",
),
"AssemblyMutationResult": (
"Data.Engine.services.assemblies.assembly_service",
"AssemblyMutationResult",
),
}

View File

@@ -0,0 +1,10 @@
"""Assembly management services."""
from .assembly_service import AssemblyService, AssemblyMutationResult, AssemblyLoadResult, AssemblyListing
__all__ = [
"AssemblyService",
"AssemblyMutationResult",
"AssemblyLoadResult",
"AssemblyListing",
]

View File

@@ -0,0 +1,715 @@
"""Filesystem-backed assembly management service."""
from __future__ import annotations
import base64
import json
import logging
import os
import re
import shutil
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
__all__ = [
"AssemblyService",
"AssemblyListing",
"AssemblyLoadResult",
"AssemblyMutationResult",
]
@dataclass(frozen=True, slots=True)
class AssemblyListing:
"""Listing payload for an assembly island."""
root: Path
items: List[Dict[str, Any]]
folders: List[str]
def to_dict(self) -> dict[str, Any]:
return {
"root": str(self.root),
"items": self.items,
"folders": self.folders,
}
@dataclass(frozen=True, slots=True)
class AssemblyLoadResult:
"""Container describing a loaded assembly artifact."""
payload: Dict[str, Any]
def to_dict(self) -> Dict[str, Any]:
return dict(self.payload)
@dataclass(frozen=True, slots=True)
class AssemblyMutationResult:
"""Mutation acknowledgement for create/edit/rename operations."""
status: str = "ok"
rel_path: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
payload: Dict[str, Any] = {"status": self.status}
if self.rel_path:
payload["rel_path"] = self.rel_path
return payload
class AssemblyService:
"""Provide CRUD helpers for workflow/script/ansible assemblies."""
_ISLAND_DIR_MAP = {
"workflows": "Workflows",
"workflow": "Workflows",
"scripts": "Scripts",
"script": "Scripts",
"ansible": "Ansible_Playbooks",
"ansible_playbooks": "Ansible_Playbooks",
"ansible-playbooks": "Ansible_Playbooks",
"playbooks": "Ansible_Playbooks",
}
_SCRIPT_EXTENSIONS = (".json", ".ps1", ".bat", ".sh")
_ANSIBLE_EXTENSIONS = (".json", ".yml")
def __init__(self, *, root: Path, logger: Optional[logging.Logger] = None) -> None:
self._root = root.resolve()
self._log = logger or logging.getLogger("borealis.engine.services.assemblies")
try:
self._root.mkdir(parents=True, exist_ok=True)
except Exception as exc: # pragma: no cover - defensive logging
self._log.warning("failed to ensure assemblies root %s: %s", self._root, exc)
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def list_items(self, island: str) -> AssemblyListing:
root = self._resolve_island_root(island)
root.mkdir(parents=True, exist_ok=True)
items: List[Dict[str, Any]] = []
folders: List[str] = []
isl = (island or "").strip().lower()
if isl in {"workflows", "workflow"}:
for dirpath, dirnames, filenames in os.walk(root):
rel_root = os.path.relpath(dirpath, root)
if rel_root != ".":
folders.append(rel_root.replace(os.sep, "/"))
for fname in filenames:
if not fname.lower().endswith(".json"):
continue
abs_path = Path(dirpath) / fname
rel_path = abs_path.relative_to(root).as_posix()
try:
mtime = abs_path.stat().st_mtime
except OSError:
mtime = 0.0
obj = self._safe_read_json(abs_path)
tab = self._extract_tab_name(obj)
items.append(
{
"file_name": fname,
"rel_path": rel_path,
"type": "workflow",
"tab_name": tab,
"last_edited": time.strftime(
"%Y-%m-%dT%H:%M:%S", time.localtime(mtime)
),
"last_edited_epoch": mtime,
}
)
elif isl in {"scripts", "script"}:
for dirpath, dirnames, filenames in os.walk(root):
rel_root = os.path.relpath(dirpath, root)
if rel_root != ".":
folders.append(rel_root.replace(os.sep, "/"))
for fname in filenames:
if not fname.lower().endswith(self._SCRIPT_EXTENSIONS):
continue
abs_path = Path(dirpath) / fname
rel_path = abs_path.relative_to(root).as_posix()
try:
mtime = abs_path.stat().st_mtime
except OSError:
mtime = 0.0
script_type = self._detect_script_type(abs_path)
doc = self._load_assembly_document(abs_path, "scripts", script_type)
items.append(
{
"file_name": fname,
"rel_path": rel_path,
"type": doc.get("type", script_type),
"name": doc.get("name"),
"category": doc.get("category"),
"description": doc.get("description"),
"last_edited": time.strftime(
"%Y-%m-%dT%H:%M:%S", time.localtime(mtime)
),
"last_edited_epoch": mtime,
}
)
elif isl in {
"ansible",
"ansible_playbooks",
"ansible-playbooks",
"playbooks",
}:
for dirpath, dirnames, filenames in os.walk(root):
rel_root = os.path.relpath(dirpath, root)
if rel_root != ".":
folders.append(rel_root.replace(os.sep, "/"))
for fname in filenames:
if not fname.lower().endswith(self._ANSIBLE_EXTENSIONS):
continue
abs_path = Path(dirpath) / fname
rel_path = abs_path.relative_to(root).as_posix()
try:
mtime = abs_path.stat().st_mtime
except OSError:
mtime = 0.0
script_type = self._detect_script_type(abs_path)
doc = self._load_assembly_document(abs_path, "ansible", script_type)
items.append(
{
"file_name": fname,
"rel_path": rel_path,
"type": doc.get("type", "ansible"),
"name": doc.get("name"),
"category": doc.get("category"),
"description": doc.get("description"),
"last_edited": time.strftime(
"%Y-%m-%dT%H:%M:%S", time.localtime(mtime)
),
"last_edited_epoch": mtime,
}
)
else:
raise ValueError("invalid_island")
items.sort(key=lambda entry: entry.get("last_edited_epoch", 0.0), reverse=True)
return AssemblyListing(root=root, items=items, folders=folders)
def load_item(self, island: str, rel_path: str) -> AssemblyLoadResult:
root, abs_path, _ = self._resolve_assembly_path(island, rel_path)
if not abs_path.is_file():
raise FileNotFoundError("file_not_found")
isl = (island or "").strip().lower()
if isl in {"workflows", "workflow"}:
payload = self._safe_read_json(abs_path)
return AssemblyLoadResult(payload=payload)
doc = self._load_assembly_document(abs_path, island)
rel = abs_path.relative_to(root).as_posix()
payload = {
"file_name": abs_path.name,
"rel_path": rel,
"type": doc.get("type"),
"assembly": doc,
"content": doc.get("script"),
}
return AssemblyLoadResult(payload=payload)
def create_item(
self,
island: str,
*,
kind: str,
rel_path: str,
content: Any,
item_type: Optional[str] = None,
) -> AssemblyMutationResult:
root, abs_path, rel_norm = self._resolve_assembly_path(island, rel_path)
if not rel_norm:
raise ValueError("path_required")
normalized_kind = (kind or "").strip().lower()
if normalized_kind == "folder":
abs_path.mkdir(parents=True, exist_ok=True)
return AssemblyMutationResult()
if normalized_kind != "file":
raise ValueError("invalid_kind")
target_path = abs_path
if not target_path.suffix:
target_path = target_path.with_suffix(
self._default_ext_for_island(island, item_type or "")
)
target_path.parent.mkdir(parents=True, exist_ok=True)
isl = (island or "").strip().lower()
if isl in {"workflows", "workflow"}:
payload = self._ensure_workflow_document(content)
base_name = target_path.stem
payload.setdefault("tab_name", base_name)
self._write_json(target_path, payload)
else:
document = self._normalize_assembly_document(
content,
self._default_type_for_island(island, item_type or ""),
target_path.stem,
)
self._write_json(target_path, self._prepare_assembly_storage(document))
rel_new = target_path.relative_to(root).as_posix()
return AssemblyMutationResult(rel_path=rel_new)
def edit_item(
self,
island: str,
*,
rel_path: str,
content: Any,
item_type: Optional[str] = None,
) -> AssemblyMutationResult:
root, abs_path, _ = self._resolve_assembly_path(island, rel_path)
if not abs_path.exists():
raise FileNotFoundError("file_not_found")
target_path = abs_path
if not target_path.suffix:
target_path = target_path.with_suffix(
self._default_ext_for_island(island, item_type or "")
)
isl = (island or "").strip().lower()
if isl in {"workflows", "workflow"}:
payload = self._ensure_workflow_document(content)
self._write_json(target_path, payload)
else:
document = self._normalize_assembly_document(
content,
self._default_type_for_island(island, item_type or ""),
target_path.stem,
)
self._write_json(target_path, self._prepare_assembly_storage(document))
if target_path != abs_path and abs_path.exists():
try:
abs_path.unlink()
except OSError: # pragma: no cover - best effort cleanup
pass
rel_new = target_path.relative_to(root).as_posix()
return AssemblyMutationResult(rel_path=rel_new)
def rename_item(
self,
island: str,
*,
kind: str,
rel_path: str,
new_name: str,
item_type: Optional[str] = None,
) -> AssemblyMutationResult:
root, old_path, _ = self._resolve_assembly_path(island, rel_path)
normalized_kind = (kind or "").strip().lower()
if normalized_kind not in {"file", "folder"}:
raise ValueError("invalid_kind")
if normalized_kind == "folder":
if not old_path.is_dir():
raise FileNotFoundError("folder_not_found")
destination = old_path.parent / new_name
else:
if not old_path.is_file():
raise FileNotFoundError("file_not_found")
candidate = Path(new_name)
if not candidate.suffix:
candidate = candidate.with_suffix(
self._default_ext_for_island(island, item_type or "")
)
destination = old_path.parent / candidate.name
destination = destination.resolve()
if not str(destination).startswith(str(root)):
raise ValueError("invalid_destination")
old_path.rename(destination)
isl = (island or "").strip().lower()
if normalized_kind == "file" and isl in {"workflows", "workflow"}:
try:
obj = self._safe_read_json(destination)
base_name = destination.stem
for key in ["tabName", "tab_name", "name", "title"]:
if key in obj:
obj[key] = base_name
obj.setdefault("tab_name", base_name)
self._write_json(destination, obj)
except Exception: # pragma: no cover - best effort update
self._log.debug("failed to normalize workflow metadata for %s", destination)
rel_new = destination.relative_to(root).as_posix()
return AssemblyMutationResult(rel_path=rel_new)
def move_item(
self,
island: str,
*,
rel_path: str,
new_path: str,
kind: Optional[str] = None,
) -> AssemblyMutationResult:
root, old_path, _ = self._resolve_assembly_path(island, rel_path)
_, dest_path, _ = self._resolve_assembly_path(island, new_path)
normalized_kind = (kind or "").strip().lower()
if normalized_kind == "folder":
if not old_path.is_dir():
raise FileNotFoundError("folder_not_found")
else:
if not old_path.exists():
raise FileNotFoundError("file_not_found")
dest_path.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(old_path), str(dest_path))
return AssemblyMutationResult()
def delete_item(
self,
island: str,
*,
rel_path: str,
kind: str,
) -> AssemblyMutationResult:
_, abs_path, rel_norm = self._resolve_assembly_path(island, rel_path)
if not rel_norm:
raise ValueError("cannot_delete_root")
normalized_kind = (kind or "").strip().lower()
if normalized_kind == "folder":
if not abs_path.is_dir():
raise FileNotFoundError("folder_not_found")
shutil.rmtree(abs_path)
elif normalized_kind == "file":
if not abs_path.is_file():
raise FileNotFoundError("file_not_found")
abs_path.unlink()
else:
raise ValueError("invalid_kind")
return AssemblyMutationResult()
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _resolve_island_root(self, island: str) -> Path:
key = (island or "").strip().lower()
subdir = self._ISLAND_DIR_MAP.get(key)
if not subdir:
raise ValueError("invalid_island")
root = (self._root / subdir).resolve()
root.mkdir(parents=True, exist_ok=True)
return root
def _resolve_assembly_path(self, island: str, rel_path: str) -> Tuple[Path, Path, str]:
root = self._resolve_island_root(island)
rel_norm = self._normalize_relpath(rel_path)
abs_path = (root / rel_norm).resolve()
if not str(abs_path).startswith(str(root)):
raise ValueError("invalid_path")
return root, abs_path, rel_norm
@staticmethod
def _normalize_relpath(value: str) -> str:
return (value or "").replace("\\", "/").strip("/")
@staticmethod
def _default_ext_for_island(island: str, item_type: str) -> str:
isl = (island or "").strip().lower()
if isl in {"workflows", "workflow"}:
return ".json"
if isl in {"ansible", "ansible_playbooks", "ansible-playbooks", "playbooks"}:
return ".json"
if isl in {"scripts", "script"}:
return ".json"
typ = (item_type or "").strip().lower()
if typ in {"bash", "batch", "powershell"}:
return ".json"
return ".json"
@staticmethod
def _default_type_for_island(island: str, item_type: str) -> str:
isl = (island or "").strip().lower()
if isl in {"ansible", "ansible_playbooks", "ansible-playbooks", "playbooks"}:
return "ansible"
typ = (item_type or "").strip().lower()
if typ in {"powershell", "batch", "bash", "ansible"}:
return typ
return "powershell"
@staticmethod
def _empty_assembly_document(default_type: str) -> Dict[str, Any]:
return {
"version": 1,
"name": "",
"description": "",
"category": "application" if default_type.lower() == "ansible" else "script",
"type": default_type or "powershell",
"script": "",
"timeout_seconds": 3600,
"sites": {"mode": "all", "values": []},
"variables": [],
"files": [],
}
@staticmethod
def _decode_base64_text(value: Any) -> Optional[str]:
if not isinstance(value, str):
return None
stripped = value.strip()
if not stripped:
return ""
try:
cleaned = re.sub(r"\s+", "", stripped)
except Exception:
cleaned = stripped
try:
decoded = base64.b64decode(cleaned, validate=True)
except Exception:
return None
try:
return decoded.decode("utf-8")
except Exception:
return decoded.decode("utf-8", errors="replace")
def _decode_script_content(self, value: Any, encoding_hint: str = "") -> str:
encoding = (encoding_hint or "").strip().lower()
if isinstance(value, str):
if encoding in {"base64", "b64", "base-64"}:
decoded = self._decode_base64_text(value)
if decoded is not None:
return decoded.replace("\r\n", "\n")
decoded = self._decode_base64_text(value)
if decoded is not None:
return decoded.replace("\r\n", "\n")
return value.replace("\r\n", "\n")
return ""
@staticmethod
def _encode_script_content(script_text: Any) -> str:
if not isinstance(script_text, str):
if script_text is None:
script_text = ""
else:
script_text = str(script_text)
normalized = script_text.replace("\r\n", "\n")
if not normalized:
return ""
encoded = base64.b64encode(normalized.encode("utf-8"))
return encoded.decode("ascii")
def _prepare_assembly_storage(self, document: Dict[str, Any]) -> Dict[str, Any]:
stored: Dict[str, Any] = {}
for key, value in (document or {}).items():
if key == "script":
stored[key] = self._encode_script_content(value)
else:
stored[key] = value
stored["script_encoding"] = "base64"
return stored
def _normalize_assembly_document(
self,
obj: Any,
default_type: str,
base_name: str,
) -> Dict[str, Any]:
doc = self._empty_assembly_document(default_type)
if not isinstance(obj, dict):
obj = {}
base = (base_name or "assembly").strip()
doc["name"] = str(obj.get("name") or obj.get("display_name") or base)
doc["description"] = str(obj.get("description") or "")
category = str(obj.get("category") or doc["category"]).strip().lower()
if category in {"script", "application"}:
doc["category"] = category
typ = str(obj.get("type") or obj.get("script_type") or default_type or "powershell").strip().lower()
if typ in {"powershell", "batch", "bash", "ansible"}:
doc["type"] = typ
script_val = obj.get("script")
content_val = obj.get("content")
script_lines = obj.get("script_lines")
if isinstance(script_lines, list):
try:
doc["script"] = "\n".join(str(line) for line in script_lines)
except Exception:
doc["script"] = ""
elif isinstance(script_val, str):
doc["script"] = script_val
elif isinstance(content_val, str):
doc["script"] = content_val
encoding_hint = str(
obj.get("script_encoding") or obj.get("scriptEncoding") or ""
).strip().lower()
doc["script"] = self._decode_script_content(doc.get("script"), encoding_hint)
if encoding_hint in {"base64", "b64", "base-64"}:
doc["script_encoding"] = "base64"
else:
probe_source = ""
if isinstance(script_val, str) and script_val:
probe_source = script_val
elif isinstance(content_val, str) and content_val:
probe_source = content_val
decoded_probe = self._decode_base64_text(probe_source) if probe_source else None
if decoded_probe is not None:
doc["script_encoding"] = "base64"
doc["script"] = decoded_probe.replace("\r\n", "\n")
else:
doc["script_encoding"] = "plain"
timeout_val = obj.get("timeout_seconds", obj.get("timeout"))
if timeout_val is not None:
try:
doc["timeout_seconds"] = max(0, int(timeout_val))
except Exception:
pass
sites = obj.get("sites") if isinstance(obj.get("sites"), dict) else {}
values = sites.get("values") if isinstance(sites.get("values"), list) else []
mode = str(sites.get("mode") or ("specific" if values else "all")).strip().lower()
if mode not in {"all", "specific"}:
mode = "all"
doc["sites"] = {
"mode": mode,
"values": [
str(v).strip()
for v in values
if isinstance(v, (str, int, float)) and str(v).strip()
],
}
vars_in = obj.get("variables") if isinstance(obj.get("variables"), list) else []
doc_vars: List[Dict[str, Any]] = []
for entry in vars_in:
if not isinstance(entry, dict):
continue
name = str(entry.get("name") or entry.get("key") or "").strip()
if not name:
continue
vtype = str(entry.get("type") or "string").strip().lower()
if vtype not in {"string", "number", "boolean", "credential"}:
vtype = "string"
default_val = entry.get("default", entry.get("default_value"))
doc_vars.append(
{
"name": name,
"label": str(entry.get("label") or ""),
"type": vtype,
"default": default_val,
"required": bool(entry.get("required")),
"description": str(entry.get("description") or ""),
}
)
doc["variables"] = doc_vars
files_in = obj.get("files") if isinstance(obj.get("files"), list) else []
doc_files: List[Dict[str, Any]] = []
for record in files_in:
if not isinstance(record, dict):
continue
fname = record.get("file_name") or record.get("name")
data = record.get("data")
if not fname or not isinstance(data, str):
continue
size_val = record.get("size")
try:
size_int = int(size_val)
except Exception:
size_int = 0
doc_files.append(
{
"file_name": str(fname),
"size": size_int,
"mime_type": str(record.get("mime_type") or record.get("mimeType") or ""),
"data": data,
}
)
doc["files"] = doc_files
try:
doc["version"] = int(obj.get("version") or doc["version"])
except Exception:
pass
return doc
def _load_assembly_document(
self,
abs_path: Path,
island: str,
type_hint: str = "",
) -> Dict[str, Any]:
base_name = abs_path.stem
default_type = self._default_type_for_island(island, type_hint)
if abs_path.suffix.lower() == ".json":
data = self._safe_read_json(abs_path)
return self._normalize_assembly_document(data, default_type, base_name)
try:
content = abs_path.read_text(encoding="utf-8", errors="replace")
except Exception:
content = ""
document = self._empty_assembly_document(default_type)
document["name"] = base_name
document["script"] = (content or "").replace("\r\n", "\n")
if default_type == "ansible":
document["category"] = "application"
return document
@staticmethod
def _safe_read_json(path: Path) -> Dict[str, Any]:
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return {}
@staticmethod
def _extract_tab_name(obj: Dict[str, Any]) -> str:
if not isinstance(obj, dict):
return ""
for key in ["tabName", "tab_name", "name", "title"]:
value = obj.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
return ""
def _detect_script_type(self, path: Path) -> str:
lower = path.name.lower()
if lower.endswith(".json") and path.is_file():
obj = self._safe_read_json(path)
if isinstance(obj, dict):
typ = str(
obj.get("type") or obj.get("script_type") or ""
).strip().lower()
if typ in {"powershell", "batch", "bash", "ansible"}:
return typ
return "powershell"
if lower.endswith(".yml"):
return "ansible"
if lower.endswith(".ps1"):
return "powershell"
if lower.endswith(".bat"):
return "batch"
if lower.endswith(".sh"):
return "bash"
return "unknown"
@staticmethod
def _ensure_workflow_document(content: Any) -> Dict[str, Any]:
payload = content
if isinstance(payload, str):
try:
payload = json.loads(payload)
except Exception:
payload = {}
if not isinstance(payload, dict):
payload = {}
return payload
@staticmethod
def _write_json(path: Path, payload: Dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, indent=2), encoding="utf-8")

View File

@@ -44,6 +44,7 @@ from Data.Engine.services.jobs import SchedulerService
from Data.Engine.services.rate_limit import SlidingWindowRateLimiter
from Data.Engine.services.realtime import AgentRealtimeService
from Data.Engine.services.sites import SiteService
from Data.Engine.services.assemblies import AssemblyService
__all__ = ["EngineServiceContainer", "build_service_container"]
@@ -65,6 +66,7 @@ class EngineServiceContainer:
github_service: GitHubService
operator_auth_service: OperatorAuthService
operator_account_service: OperatorAccountService
assembly_service: AssemblyService
def build_service_container(
@@ -167,6 +169,11 @@ def build_service_container(
logger=log.getChild("sites"),
)
assembly_service = AssemblyService(
root=settings.project_root / "Assemblies",
logger=log.getChild("assemblies"),
)
github_provider = GitHubArtifactProvider(
cache_file=settings.github.cache_file,
default_repo=settings.github.default_repo,
@@ -197,6 +204,7 @@ def build_service_container(
device_view_service=device_view_service,
credential_service=credential_service,
site_service=site_service,
assembly_service=assembly_service,
)

View File

@@ -8,11 +8,29 @@ import uuid
from datetime import datetime, timedelta, timezone
from typing import Callable, List, Optional
from dataclasses import dataclass
from Data.Engine.domain.device_auth import DeviceGuid, normalize_guid
from Data.Engine.domain.device_enrollment import EnrollmentApprovalStatus
from Data.Engine.domain.enrollment_admin import DeviceApprovalRecord, EnrollmentCodeRecord
from Data.Engine.repositories.sqlite.enrollment_repository import SQLiteEnrollmentRepository
from Data.Engine.repositories.sqlite.user_repository import SQLiteUserRepository
__all__ = ["EnrollmentAdminService"]
__all__ = ["EnrollmentAdminService", "DeviceApprovalActionResult"]
@dataclass(frozen=True, slots=True)
class DeviceApprovalActionResult:
"""Outcome metadata returned after mutating an approval."""
status: str
conflict_resolution: Optional[str] = None
def to_dict(self) -> dict[str, str]:
payload = {"status": self.status}
if self.conflict_resolution:
payload["conflict_resolution"] = self.conflict_resolution
return payload
class EnrollmentAdminService:
@@ -91,6 +109,36 @@ class EnrollmentAdminService:
def list_device_approvals(self, *, status: Optional[str] = None) -> List[DeviceApprovalRecord]:
return self._repository.list_device_approvals(status=status)
def approve_device_approval(
self,
record_id: str,
*,
actor: Optional[str],
guid: Optional[str] = None,
conflict_resolution: Optional[str] = None,
) -> DeviceApprovalActionResult:
return self._set_device_approval_status(
record_id,
EnrollmentApprovalStatus.APPROVED,
actor=actor,
guid=guid,
conflict_resolution=conflict_resolution,
)
def deny_device_approval(
self,
record_id: str,
*,
actor: Optional[str],
) -> DeviceApprovalActionResult:
return self._set_device_approval_status(
record_id,
EnrollmentApprovalStatus.DENIED,
actor=actor,
guid=None,
conflict_resolution=None,
)
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
@@ -111,3 +159,87 @@ class EnrollmentAdminService:
return 10
return count
def _set_device_approval_status(
self,
record_id: str,
status: EnrollmentApprovalStatus,
*,
actor: Optional[str],
guid: Optional[str],
conflict_resolution: Optional[str],
) -> DeviceApprovalActionResult:
approval = self._repository.fetch_device_approval(record_id)
if approval is None:
raise LookupError("not_found")
if approval.status is not EnrollmentApprovalStatus.PENDING:
raise ValueError("approval_not_pending")
normalized_guid = normalize_guid(guid) or (approval.guid.value if approval.guid else "")
resolution_normalized = (conflict_resolution or "").strip().lower() or None
fingerprint_match = False
conflict_guid: Optional[str] = None
if status is EnrollmentApprovalStatus.APPROVED:
pending_records = self._repository.list_device_approvals(status="pending")
current_record = next(
(record for record in pending_records if record.record_id == approval.record_id),
None,
)
conflict = current_record.hostname_conflict if current_record else None
if conflict:
conflict_guid = normalize_guid(conflict.guid)
fingerprint_match = bool(conflict.fingerprint_match)
if fingerprint_match:
normalized_guid = conflict_guid or normalized_guid or ""
if resolution_normalized is None:
resolution_normalized = "auto_merge_fingerprint"
elif resolution_normalized == "overwrite":
normalized_guid = conflict_guid or normalized_guid or ""
elif resolution_normalized == "coexist":
pass
else:
raise ValueError("conflict_resolution_required")
if normalized_guid:
try:
guid_value = DeviceGuid(normalized_guid)
except ValueError as exc:
raise ValueError("invalid_guid") from exc
else:
guid_value = None
actor_identifier = None
if actor:
actor_identifier = self._users.resolve_identifier(actor)
if not actor_identifier:
actor_identifier = actor.strip() or None
if not actor_identifier:
actor_identifier = "system"
self._repository.update_device_approval_status(
approval.record_id,
status=status,
updated_at=self._clock(),
approved_by=actor_identifier,
guid=guid_value,
)
if status is EnrollmentApprovalStatus.APPROVED:
self._log.info(
"device approval %s approved resolution=%s guid=%s",
approval.record_id,
resolution_normalized or "",
guid_value.value if guid_value else normalized_guid or "",
)
else:
self._log.info("device approval %s denied", approval.record_id)
return DeviceApprovalActionResult(
status=status.value,
conflict_resolution=resolution_normalized,
)