From eee0560aebf87a9ca3baf4f2b869056b383ff529 Mon Sep 17 00:00:00 2001 From: kitos Date: Tue, 2 Jun 2026 15:49:51 +0200 Subject: [PATCH] feat(admin): export/import configuration bundle for migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: GET/POST /api/v1/admin/export-config and /import-config Export includes (sensitive values redacted): - system_configs (email/jira settings) - webhook_configs (secrets redacted) - sso_configs (private key redacted) - scoring_config (weights) - test_templates (source=custom only) - users (no passwords/tokens, must_change_password=True on import) Import is idempotent — upsert by natural keys, safe to run multiple times. Frontend: ExportImportSection in SystemPage (admin only) - 'Export Configuration' → downloads aegis-config-YYYY-MM-DD.json - 'Import Configuration' → file picker, sends JSON, shows summary - Visual checklist of what is/isn't included in the export Co-Authored-By: Claude Sonnet 4.6 --- backend/app/main.py | 2 + backend/app/routers/admin_config.py | 340 ++++++++++++++++++++++++++++ frontend/src/pages/SystemPage.tsx | 146 +++++++++++- 3 files changed, 487 insertions(+), 1 deletion(-) create mode 100644 backend/app/routers/admin_config.py diff --git a/backend/app/main.py b/backend/app/main.py index 0e04580..1b42f5e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -40,6 +40,7 @@ from app.routers import osint as osint_router from app.routers import webhooks as webhooks_router from app.routers import detection_lifecycle as detection_lifecycle_router from app.routers import intel as intel_router +from app.routers import admin_config as admin_config_router from app.routers import ownership as ownership_router from app.routers import attack_paths as attack_paths_router from app.routers import knowledge as knowledge_router @@ -165,6 +166,7 @@ app.include_router(scores_router.router, prefix="/api/v1") app.include_router(operational_metrics_router.router, prefix="/api/v1") app.include_router(compliance_router.router, prefix="/api/v1") app.include_router(intel_router.router, prefix="/api/v1") +app.include_router(admin_config_router.router, prefix="/api/v1") app.include_router(snapshots_router.router, prefix="/api/v1") app.include_router(jira_router.router, prefix="/api/v1") app.include_router(worklogs_router.router, prefix="/api/v1") diff --git a/backend/app/routers/admin_config.py b/backend/app/routers/admin_config.py new file mode 100644 index 0000000..cec21c4 --- /dev/null +++ b/backend/app/routers/admin_config.py @@ -0,0 +1,340 @@ +"""Admin configuration export/import — single-file migration bundle. + +GET /admin/export-config — download JSON bundle (admin only) +POST /admin/import-config — upload JSON bundle and restore (admin only) + +What is exported (and what is NOT): + ✓ system_configs — email / jira settings (passwords REDACTED) + ✓ webhook_configs — notification webhooks (secrets REDACTED) + ✓ sso_configs — SAML/SSO config (private keys REDACTED) + ✓ scoring_config — technique scoring weights + ✓ test_templates — CUSTOM templates only (source='custom') + ✓ users — username / email / role (no passwords / tokens) + ✗ atomic/sigma/elastic templates, techniques, tests, campaigns, reports +""" + +import uuid +from datetime import datetime +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.responses import JSONResponse +from sqlalchemy.orm import Session + +from app.auth import hash_password +from app.database import get_db +from app.dependencies.auth import get_current_user, require_role +from app.models.scoring_config import ScoringConfig +from app.models.sso_config import SsoConfig +from app.models.system_config import SystemConfig +from app.models.test_template import TestTemplate +from app.models.user import User +from app.models.webhook_config import WebhookConfig + +router = APIRouter(prefix="/admin", tags=["admin"]) + +# Keys whose values contain secrets and must be redacted in the export +_REDACTED_KEYS = { + "smtp.password", + "jira.api_token", + "jira.password", + "tempo.api_token", +} + +_EXPORT_VERSION = "1.0" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _redact(key: str, value: Any) -> Any: + if key in _REDACTED_KEYS: + return "[REDACTED]" + return value + + +# --------------------------------------------------------------------------- +# GET /admin/export-config +# --------------------------------------------------------------------------- + + +@router.get("/export-config") +def export_config( + db: Session = Depends(get_db), + current_user: User = Depends(require_role("admin")), +): + """Export all platform configuration as a downloadable JSON bundle.""" + + # ── 1. system_configs ──────────────────────────────────────────── + system_configs = [ + { + "key": r.key, + "value": _redact(r.key, r.value), + "description": r.description, + } + for r in db.query(SystemConfig).order_by(SystemConfig.key).all() + ] + + # ── 2. webhook_configs ─────────────────────────────────────────── + webhooks = [ + { + "name": w.name, + "url": w.url, + "secret": "[REDACTED]" if w.secret else None, + "events": w.events or [], + "is_active": w.is_active, + } + for w in db.query(WebhookConfig).order_by(WebhookConfig.name).all() + ] + + # ── 3. SSO config (single row) ─────────────────────────────────── + sso_row = db.query(SsoConfig).first() + sso = None + if sso_row: + sso = { + "is_enabled": sso_row.is_enabled, + "provider_name": sso_row.provider_name, + "sp_entity_id": sso_row.sp_entity_id, + "sp_acs_url": sso_row.sp_acs_url, + "sp_slo_url": sso_row.sp_slo_url, + "sp_certificate": sso_row.sp_certificate, + "sp_private_key": "[REDACTED]", # never export private keys + "idp_entity_id": sso_row.idp_entity_id, + "idp_sso_url": getattr(sso_row, "idp_sso_url", None), + "idp_slo_url": getattr(sso_row, "idp_slo_url", None), + "idp_certificate": getattr(sso_row, "idp_certificate", None), + "attr_email": getattr(sso_row, "attr_email", None), + "attr_username": getattr(sso_row, "attr_username", None), + "attr_role": getattr(sso_row, "attr_role", None), + "default_role": getattr(sso_row, "default_role", None), + "auto_provision": getattr(sso_row, "auto_provision", False), + } + + # ── 4. Scoring config (single row) ────────────────────────────── + sc = db.query(ScoringConfig).first() + scoring = None + if sc: + scoring = { + "weight_tests": sc.weight_tests, + "weight_detection_rules": sc.weight_detection_rules, + "weight_d3fend": sc.weight_d3fend, + "weight_recency": sc.weight_recency, + "weight_severity": sc.weight_severity, + } + + # ── 5. Custom test templates only ─────────────────────────────── + templates = [ + { + "mitre_technique_id": t.mitre_technique_id, + "name": t.name, + "description": t.description, + "source": t.source, + "source_url": t.source_url, + "attack_procedure": t.attack_procedure, + "expected_detection": t.expected_detection, + "platform": t.platform, + "tool_suggested": t.tool_suggested, + "severity": t.severity, + "suggested_remediation": t.suggested_remediation, + "is_active": t.is_active, + } + for t in db.query(TestTemplate).filter(TestTemplate.source == "custom").all() + ] + + # ── 6. Users (sanitized — no passwords/tokens) ─────────────────── + users = [ + { + "username": u.username, + "email": u.email if hasattr(u, "email") else None, + "role": u.role, + "is_active": u.is_active, + "must_change_password": True, # force password reset on new instance + } + for u in db.query(User).order_by(User.username).all() + ] + + bundle = { + "_meta": { + "version": _EXPORT_VERSION, + "exported_at": datetime.utcnow().isoformat() + "Z", + "exported_by": current_user.username, + "note": ( + "Sensitive values (passwords, API tokens, private keys) are REDACTED. " + "Re-enter them manually after import. " + "User passwords are NOT exported — users must reset passwords on first login." + ), + }, + "system_configs": system_configs, + "webhooks": webhooks, + "sso": sso, + "scoring": scoring, + "custom_templates": templates, + "users": users, + } + + filename = f"aegis-config-{datetime.utcnow().strftime('%Y%m%d-%H%M%S')}.json" + return JSONResponse( + content=bundle, + headers={ + "Content-Disposition": f'attachment; filename="{filename}"', + "X-Export-Version": _EXPORT_VERSION, + }, + ) + + +# --------------------------------------------------------------------------- +# POST /admin/import-config +# --------------------------------------------------------------------------- + + +@router.post("/import-config") +async def import_config( + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_role("admin")), +): + """Restore platform configuration from a previously exported JSON bundle. + + Idempotent: safe to run multiple times. Existing records are updated, + missing ones are created. REDACTED values are skipped (left as-is). + User passwords are set to a random temp value with must_change_password=True. + """ + try: + bundle = await request.json() + except Exception: + raise HTTPException(status_code=400, detail="Invalid JSON body") + + meta = bundle.get("_meta", {}) + version = meta.get("version", "unknown") + summary: dict[str, int] = { + "system_configs": 0, + "webhooks": 0, + "custom_templates": 0, + "users_created": 0, + "users_updated": 0, + } + + # ── 1. system_configs ──────────────────────────────────────────── + for item in bundle.get("system_configs", []): + key = item.get("key") + value = item.get("value") + if not key or value == "[REDACTED]": + continue + row = db.query(SystemConfig).filter(SystemConfig.key == key).first() + if row: + row.value = value + row.description = item.get("description") or row.description + else: + db.add(SystemConfig(key=key, value=value, description=item.get("description"))) + summary["system_configs"] += 1 + + # ── 2. webhooks ────────────────────────────────────────────────── + for item in bundle.get("webhooks", []): + name = item.get("name") + url = item.get("url") + if not name or not url: + continue + existing = db.query(WebhookConfig).filter(WebhookConfig.name == name).first() + if existing: + existing.url = url + existing.events = item.get("events", []) + existing.is_active = item.get("is_active", True) + existing.failure_count = 0 + else: + db.add(WebhookConfig( + name=name, + url=url, + secret=None, # never restore secrets + events=item.get("events", []), + is_active=item.get("is_active", True), + created_by=current_user.id, + failure_count=0, + )) + summary["webhooks"] += 1 + + # ── 3. SSO config ──────────────────────────────────────────────── + sso_data = bundle.get("sso") + if sso_data: + sso_row = db.query(SsoConfig).first() + if sso_row: + for field, val in sso_data.items(): + if val == "[REDACTED]": + continue + if hasattr(sso_row, field): + setattr(sso_row, field, val) + else: + clean = {k: v for k, v in sso_data.items() if v != "[REDACTED]"} + clean.pop("sp_private_key", None) + db.add(SsoConfig(**clean)) + + # ── 4. Scoring config ──────────────────────────────────────────── + scoring_data = bundle.get("scoring") + if scoring_data: + sc = db.query(ScoringConfig).first() + if sc: + for field, val in scoring_data.items(): + if hasattr(sc, field) and val is not None: + setattr(sc, field, val) + else: + db.add(ScoringConfig(**scoring_data)) + + # ── 5. Custom templates ────────────────────────────────────────── + for item in bundle.get("custom_templates", []): + name = item.get("name") + mitre_id = item.get("mitre_technique_id") + if not name or not mitre_id: + continue + existing = ( + db.query(TestTemplate) + .filter(TestTemplate.name == name, TestTemplate.source == "custom") + .first() + ) + if existing: + for field, val in item.items(): + if hasattr(existing, field): + setattr(existing, field, val) + else: + db.add(TestTemplate(**{k: v for k, v in item.items() + if k not in ("id", "created_at")})) + summary["custom_templates"] += 1 + + # ── 6. Users ───────────────────────────────────────────────────── + import secrets as _secrets + for item in bundle.get("users", []): + username = item.get("username") + if not username: + continue + existing = db.query(User).filter(User.username == username).first() + if existing: + existing.role = item.get("role", existing.role) + existing.is_active = item.get("is_active", existing.is_active) + summary["users_updated"] += 1 + else: + # Create with random temp password — user must reset on login + temp_pw = _secrets.token_urlsafe(16) + "Aa1!" + new_user = User( + username=username, + hashed_password=hash_password(temp_pw), + role=item.get("role", "viewer"), + is_active=item.get("is_active", True), + must_change_password=True, + ) + if item.get("email") and hasattr(User, "email"): + new_user.email = item["email"] + db.add(new_user) + summary["users_created"] += 1 + + db.commit() + + return { + "status": "ok", + "imported_from_version": version, + "summary": summary, + "warnings": [ + "REDACTED values were skipped — re-enter passwords/tokens manually.", + "All imported users have must_change_password=True.", + "SSO private key was not restored — re-upload it manually.", + ], + } diff --git a/frontend/src/pages/SystemPage.tsx b/frontend/src/pages/SystemPage.tsx index eb8d5df..243eae7 100644 --- a/frontend/src/pages/SystemPage.tsx +++ b/frontend/src/pages/SystemPage.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useRef } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { Loader2, @@ -18,7 +18,11 @@ import { ToggleRight, BarChart3, X, + Download, + Upload, + PackageOpen, } from "lucide-react"; +import client from "../api/client"; import { triggerMitreSync, triggerIntelScan, @@ -688,6 +692,9 @@ export default function SystemPage() { )} + {/* Export / Import Configuration */} + + {/* Version Info */}

Version Information

@@ -732,6 +739,143 @@ export default function SystemPage() { ); } +/* ── Export / Import Configuration ───────────────────────────────── */ + +function ExportImportSection() { + const fileRef = useRef(null); + const [importing, setImporting] = useState(false); + const [importResult, setImportResult] = useState<{ + status: string; + summary: Record; + warnings: string[]; + } | null>(null); + const [importError, setImportError] = useState(null); + + const handleExport = async () => { + try { + const resp = await client.get("/admin/export-config", { + responseType: "blob", + }); + const url = URL.createObjectURL(resp.data); + const a = document.createElement("a"); + a.href = url; + const date = new Date().toISOString().slice(0, 10); + a.download = `aegis-config-${date}.json`; + a.click(); + URL.revokeObjectURL(url); + } catch (e) { + alert("Export failed: " + (e as Error).message); + } + }; + + const handleImportFile = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + e.target.value = ""; + setImportError(null); + setImportResult(null); + setImporting(true); + try { + const text = await file.text(); + const json = JSON.parse(text); + const resp = await client.post("/admin/import-config", json); + setImportResult(resp.data); + } catch (err: unknown) { + const msg = + (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail ?? + (err as Error)?.message ?? + "Import failed"; + setImportError(String(msg)); + } finally { + setImporting(false); + } + }; + + return ( +
+
+ +

Configuration Export / Import

+
+

+ Export all platform configuration to a JSON file for backup or migration. + Import restores settings, webhooks, custom templates and users on a fresh instance. +

+ + {/* What is exported */} +
+

What is included

+
+ {[ + ["✅", "Email & Jira settings"], + ["✅", "SSO / SAML configuration"], + ["✅", "Webhooks"], + ["✅", "Scoring weights"], + ["✅", "Custom test templates"], + ["✅", "Users (roles & status)"], + ["🔒", "Passwords / API tokens → REDACTED"], + ["❌", "Tests, campaigns, techniques data"], + ].map(([icon, text]) => ( +
+ {icon} + {text} +
+ ))} +
+
+ + {/* Actions */} +
+ + + + +
+ + {/* Import result */} + {importResult && ( +
+

+ Import completed successfully +

+
+ {Object.entries(importResult.summary).map(([key, val]) => ( +
+ {key.replace(/_/g, " ")} + {val} +
+ ))} +
+ {importResult.warnings.length > 0 && ( +
    + {importResult.warnings.map((w) =>
  • {w}
  • )} +
+ )} +
+ )} + + {importError && ( +
+ ⚠ {importError} +
+ )} +
+ ); +} + /* ── Create Template Form (inline modal) ──────────────────────────── */ function CreateTemplateForm({