Files
audit-web/app/registry_service.py

403 lines
20 KiB
Python

"""Persistent, data-driven audit profile registry.
The manifest is the source of truth; artifacts are immutable per profile version.
All manifest mutations are serialized and committed with ``os.replace``.
"""
from __future__ import annotations
import copy
import hashlib
import json
import os
import re
import shutil
import threading
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
try:
from .config_upload import normalize_json, validate_xlsx
except ImportError: # application is also launched with /app on sys.path
from config_upload import normalize_json, validate_xlsx
class RegistryError(RuntimeError):
pass
class RegistryValidationError(RegistryError, ValueError):
pass
class RegistryNotFoundError(RegistryError, LookupError):
pass
_ID_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$")
_ALIAS_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
_VERSION_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
_LEGACY = (
("windows", "Windows", "Windows_Checklist.xlsx", "windows_config.json", ("win", "winserver", "win_server")),
("centos", "CentOS", "CentOS_Checklist_NEW.xlsx", "centos_config_new.json", ("centos_linux",)),
("rhel", "RHEL (Red Hat Enterprise Linux)", "RHEL_Checklist_NEW.xlsx", "rhel_config_new.json", ("redhat", "red_hat", "red-hat")),
("ubuntu", "Ubuntu", "Ubuntu_Checklist_NEW.xlsx", "ubuntu_config_new.json", ()),
("oracle", "Oracle Linux", "Oracle_Linux_Checklist.xlsx", "oracle_linux_config.json", ("oracle_linux", "ol")),
)
class RegistryService:
def __init__(self, root: str | Path, legacy_config_root: str | Path | None = None):
self.root = Path(root)
self.legacy_config_root = Path(legacy_config_root or self.root.parent)
self.manifest_path = self.root / "registry.json"
self._lock = threading.RLock()
@staticmethod
def _id(value: str, field: str = "id") -> str:
value = (value or "").strip().lower()
if not _ID_RE.fullmatch(value):
raise RegistryValidationError(f"{field} must match {_ID_RE.pattern}")
return value
@staticmethod
def _version(value: str) -> str:
value = (value or "").strip()
if not _VERSION_RE.fullmatch(value):
raise RegistryValidationError("invalid version identifier")
return value
@staticmethod
def _alias(value: str, field: str = "alias") -> str:
value = (value or "").strip().lower()
if not _ALIAS_RE.fullmatch(value):
raise RegistryValidationError(f"{field} is invalid")
return value
@staticmethod
def _sha(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def _empty(self) -> dict[str, Any]:
return {"schema_version": 2, "revision": 0, "categories": [], "aliases": {}}
def load(self) -> dict[str, Any]:
if not self.manifest_path.exists():
return self._empty()
try:
data = json.loads(self.manifest_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise RegistryValidationError(f"invalid registry manifest: {exc}") from exc
self._validate_manifest(data)
return data
def _validate_manifest(self, data: dict[str, Any]) -> None:
if not isinstance(data, dict) or data.get("schema_version") != 2:
raise RegistryValidationError("unsupported registry schema")
seen: dict[str, set[str]] = {"category": set(), "component": set(), "profile": set()}
for category in data.get("categories", []):
cid = self._id(category.get("id"), "category id")
if cid in seen["category"]:
raise RegistryValidationError(f"duplicate category: {cid}")
seen["category"].add(cid)
for component in category.get("components", []):
coid = self._id(component.get("id"), "component id")
if coid in seen["component"]:
raise RegistryValidationError(f"duplicate component: {coid}")
seen["component"].add(coid)
for profile in component.get("profiles", []):
pid = self._id(profile.get("id"), "profile id")
if pid in seen["profile"]:
raise RegistryValidationError(f"duplicate profile: {pid}")
seen["profile"].add(pid)
versions = profile.get("versions", [])
active = [v for v in versions if v.get("active")]
if len(active) > 1:
raise RegistryValidationError(f"multiple active versions: {pid}")
def _write(self, data: dict[str, Any]) -> dict[str, Any]:
self._validate_manifest(data)
self.root.mkdir(parents=True, exist_ok=True)
data["revision"] = int(data.get("revision", 0)) + 1
data["updated_at"] = datetime.now(timezone.utc).isoformat()
temporary = self.manifest_path.with_suffix(".json.tmp")
payload = json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8") + b"\n"
with temporary.open("wb") as stream:
stream.write(payload)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, self.manifest_path)
return copy.deepcopy(data)
def _find(self, data: dict[str, Any], kind: str, object_id: str):
for category in data["categories"]:
if kind == "category" and category["id"] == object_id:
return category
for component in category.get("components", []):
if kind == "component" and component["id"] == object_id:
return component
for profile in component.get("profiles", []):
if kind == "profile" and profile["id"] == object_id:
return profile
raise RegistryNotFoundError(f"{kind} not found: {object_id}")
def create_category(self, category_id: str, name: str) -> dict[str, Any]:
category_id = self._id(category_id, "category id")
with self._lock:
data = self.load()
try:
self._find(data, "category", category_id)
except RegistryNotFoundError:
pass
else:
raise RegistryValidationError(f"duplicate category: {category_id}")
category = {"id": category_id, "name": name.strip(), "components": []}
data["categories"].append(category)
self._write(data)
return copy.deepcopy(category)
def create_component(self, category_id: str, component_id: str, name: str) -> dict[str, Any]:
component_id = self._id(component_id, "component id")
with self._lock:
data = self.load()
try:
self._find(data, "component", component_id)
except RegistryNotFoundError:
pass
else:
raise RegistryValidationError(f"duplicate component: {component_id}")
category = self._find(data, "category", category_id)
component = {"id": component_id, "name": name.strip(), "profiles": []}
category["components"].append(component)
self._write(data)
return copy.deepcopy(component)
def create_profile(self, component_id: str, profile_id: str, name: str, aliases=()) -> dict[str, Any]:
profile_id = self._id(profile_id, "profile id")
aliases = [self._alias(alias) for alias in aliases]
with self._lock:
data = self.load()
try:
self._find(data, "profile", profile_id)
except RegistryNotFoundError:
pass
else:
raise RegistryValidationError(f"duplicate profile: {profile_id}")
for alias in aliases:
if alias in data["aliases"] and data["aliases"][alias] != profile_id:
raise RegistryValidationError(f"duplicate alias: {alias}")
component = self._find(data, "component", component_id)
profile = {"id": profile_id, "name": name.strip(), "aliases": aliases,
"activation_history": [], "versions": []}
component["profiles"].append(profile)
for alias in aliases:
data["aliases"][alias] = profile_id
self._write(data)
return copy.deepcopy(profile)
def create_version(self, profile_id: str, version: str, checklist: bytes, rules: bytes,
*, activate: bool = False, created_by: str = "admin") -> dict[str, Any]:
profile_id, version = self._id(profile_id, "profile id"), self._version(version)
try:
checklist = validate_xlsx(checklist)
rules = normalize_json(rules)
except ValueError as exc:
raise RegistryValidationError(str(exc)) from exc
with self._lock:
data = self.load()
profile = self._find(data, "profile", profile_id)
if any(item["version"] == version for item in profile["versions"]):
raise RegistryValidationError(f"duplicate version: {version}")
relative = Path("artifacts") / profile_id / version
target = self.root / relative
if target.exists():
raise RegistryValidationError(f"artifact directory already exists: {version}")
temporary = self.root / "artifacts" / profile_id / f".{version}.{os.getpid()}.tmp"
temporary.mkdir(parents=True, exist_ok=False)
try:
(temporary / "checklist.xlsx").write_bytes(checklist)
(temporary / "rules.json").write_bytes(rules)
target.parent.mkdir(parents=True, exist_ok=True)
os.replace(temporary, target)
except Exception:
shutil.rmtree(temporary, ignore_errors=True)
raise
if activate:
current = next((item["version"] for item in profile["versions"] if item.get("active")), None)
if current:
profile.setdefault("activation_history", []).append(current)
for item in profile["versions"]:
item["active"] = False
item = {
"version": version, "active": bool(activate),
"created_at": datetime.now(timezone.utc).isoformat(), "created_by": created_by,
"artifacts": {"checklist": str(relative / "checklist.xlsx").replace("\\", "/"),
"rules": str(relative / "rules.json").replace("\\", "/")},
"checksums": {"checklist": self._sha(checklist), "rules": self._sha(rules)},
}
profile["versions"].append(item)
try:
self._write(data)
except Exception:
shutil.rmtree(target, ignore_errors=True)
raise
return copy.deepcopy(item)
def update_version_artifacts(self, profile_id: str, version: str,
checklist: bytes | None = None,
rules: bytes | None = None,
*, updated_by: str = "admin") -> dict[str, Any]:
"""Replace artifacts for an inactive version while preserving a backup copy."""
if checklist is None and rules is None:
raise RegistryValidationError("checklist or rules is required")
if checklist is not None:
checklist = validate_xlsx(checklist)
if rules is not None:
rules = normalize_json(rules)
with self._lock:
data = self.load()
profile = self._find(data, "profile", self._id(profile_id, "profile id"))
target_version = next((item for item in profile["versions"] if item["version"] == version), None)
if not target_version:
raise RegistryNotFoundError(f"version not found: {version}")
if target_version.get("active"):
raise RegistryValidationError("active versions are immutable; create a new version")
updates = {"checklist": checklist, "rules": rules}
for key, content in updates.items():
if content is None:
continue
target = self.root / target_version["artifacts"][key]
temporary = target.with_suffix(target.suffix + ".tmp")
backup = target.with_suffix(target.suffix + ".bak")
temporary.write_bytes(content)
if target.exists():
shutil.copy2(target, backup)
os.replace(temporary, target)
target_version["checksums"][key] = self._sha(content)
target_version["updated_at"] = datetime.now(timezone.utc).isoformat()
target_version["updated_by"] = updated_by
self._write(data)
return copy.deepcopy(target_version)
def activate_version(self, profile_id: str, version: str) -> dict[str, Any]:
with self._lock:
data = self.load()
profile = self._find(data, "profile", profile_id)
target = next((v for v in profile["versions"] if v["version"] == version), None)
if not target:
raise RegistryNotFoundError(f"version not found: {version}")
for key, relative in target["artifacts"].items():
artifact = self.root / relative
if not artifact.is_file() or self._sha(artifact.read_bytes()) != target["checksums"][key]:
raise RegistryValidationError(f"{key} checksum mismatch for {profile_id}:{version}")
current = next((item["version"] for item in profile["versions"] if item.get("active")), None)
if current and current != version:
profile.setdefault("activation_history", []).append(current)
for item in profile["versions"]:
item["active"] = item is target
target["activated_at"] = datetime.now(timezone.utc).isoformat()
self._write(data)
return copy.deepcopy(target)
def rollback(self, profile_id: str) -> dict[str, Any]:
with self._lock:
data = self.load()
profile = self._find(data, "profile", profile_id)
history = profile.setdefault("activation_history", [])
if not history:
raise RegistryValidationError("no previous version available")
previous_version = history.pop()
previous = next((v for v in profile["versions"] if v["version"] == previous_version), None)
if not previous:
raise RegistryValidationError("rollback history references a missing version")
for key, relative in previous["artifacts"].items():
artifact = self.root / relative
if not artifact.is_file() or self._sha(artifact.read_bytes()) != previous["checksums"][key]:
raise RegistryValidationError(f"{key} checksum mismatch for {profile_id}:{previous_version}")
for item in profile["versions"]:
item["active"] = item is previous
previous["activated_at"] = datetime.now(timezone.utc).isoformat()
self._write(data)
return copy.deepcopy(previous)
def resolve(self, *, os_type: str | None = None, profile_id: str | None = None) -> dict[str, Any]:
data = self.load()
if profile_id:
resolved_id = self._id(profile_id, "profile id")
elif os_type:
alias = self._alias(os_type, "os_type")
resolved_id = data["aliases"].get(alias, alias if alias.endswith("-baseline") else "")
else:
raise RegistryValidationError("profile_id or os_type is required")
if not resolved_id:
raise RegistryNotFoundError(f"unknown os_type: {os_type}")
profile = self._find(data, "profile", resolved_id)
version = next((v for v in profile["versions"] if v.get("active")), None)
if not version:
raise RegistryNotFoundError(f"no active version for profile: {resolved_id}")
paths = {key: self.root / relative for key, relative in version["artifacts"].items()}
for key, path in paths.items():
if not path.is_file() or self._sha(path.read_bytes()) != version["checksums"][key]:
raise RegistryValidationError(f"{key} checksum mismatch for {resolved_id}:{version['version']}")
return {"profile": copy.deepcopy(profile), "version": copy.deepcopy(version),
"checklist_path": str(paths["checklist"]), "rules_path": str(paths["rules"])}
def seed_legacy_profiles(self) -> dict[str, Any]:
with self._lock:
if self.manifest_path.exists():
return self.load()
data = self._empty()
category = {"id": "operating-systems", "name": "Operating Systems", "components": []}
data["categories"].append(category)
for os_id, name, checklist_name, rules_name, extra_aliases in _LEGACY:
profile_id = f"{os_id}-baseline"
aliases = [os_id, *extra_aliases]
checklist_path = self.legacy_config_root / checklist_name
rules_path = self.legacy_config_root / rules_name
if not checklist_path.is_file() or not rules_path.is_file():
raise RegistryValidationError(f"legacy artifacts missing for {os_id}")
checklist = validate_xlsx(checklist_path.read_bytes())
rules = normalize_json(rules_path.read_bytes())
relative = Path("artifacts") / profile_id / "legacy-1"
target = self.root / relative
target.mkdir(parents=True, exist_ok=True)
(target / "checklist.xlsx").write_bytes(checklist)
(target / "rules.json").write_bytes(rules)
version = {
"version": "legacy-1", "active": True,
"created_at": datetime.now(timezone.utc).isoformat(), "created_by": "migration",
"artifacts": {"checklist": str(relative / "checklist.xlsx").replace("\\", "/"),
"rules": str(relative / "rules.json").replace("\\", "/")},
"checksums": {"checklist": self._sha(checklist), "rules": self._sha(rules)},
"legacy_sources": {"checklist": checklist_name, "rules": rules_name},
}
profile = {"id": profile_id, "name": f"{name} baseline", "aliases": aliases,
"activation_history": [],
"versions": [version]}
category["components"].append({"id": os_id, "name": name, "profiles": [profile]})
for alias in aliases:
data["aliases"][alias] = profile_id
return self._write(data)
def public_catalog(self, active_only: bool = False) -> dict[str, Any]:
"""Return catalog metadata without artifact paths, checksums or legacy source paths."""
data = copy.deepcopy(self.load())
data.pop("aliases", None)
for category in data["categories"]:
for component in category["components"]:
for profile in component["profiles"]:
versions = profile.get("versions", [])
if active_only:
versions = [version for version in versions if version.get("active")]
profile["versions"] = [
{
"version": version["version"],
"active": bool(version.get("active")),
"created_at": version.get("created_at"),
"created_by": version.get("created_by"),
"activated_at": version.get("activated_at"),
}
for version in versions
]
return data