feat(admin): export/import configuration bundle for migration
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled

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 <noreply@anthropic.com>
This commit is contained in:
kitos
2026-06-02 15:49:51 +02:00
parent 922fb251da
commit eee0560aeb
3 changed files with 487 additions and 1 deletions

View File

@@ -40,6 +40,7 @@ from app.routers import osint as osint_router
from app.routers import webhooks as webhooks_router from app.routers import webhooks as webhooks_router
from app.routers import detection_lifecycle as detection_lifecycle_router from app.routers import detection_lifecycle as detection_lifecycle_router
from app.routers import intel as intel_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 ownership as ownership_router
from app.routers import attack_paths as attack_paths_router from app.routers import attack_paths as attack_paths_router
from app.routers import knowledge as knowledge_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(operational_metrics_router.router, prefix="/api/v1")
app.include_router(compliance_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(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(snapshots_router.router, prefix="/api/v1")
app.include_router(jira_router.router, prefix="/api/v1") app.include_router(jira_router.router, prefix="/api/v1")
app.include_router(worklogs_router.router, prefix="/api/v1") app.include_router(worklogs_router.router, prefix="/api/v1")

View File

@@ -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.",
],
}

View File

@@ -1,4 +1,4 @@
import { useState } from "react"; import { useState, useRef } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { import {
Loader2, Loader2,
@@ -18,7 +18,11 @@ import {
ToggleRight, ToggleRight,
BarChart3, BarChart3,
X, X,
Download,
Upload,
PackageOpen,
} from "lucide-react"; } from "lucide-react";
import client from "../api/client";
import { import {
triggerMitreSync, triggerMitreSync,
triggerIntelScan, triggerIntelScan,
@@ -688,6 +692,9 @@ export default function SystemPage() {
)} )}
</div> </div>
{/* Export / Import Configuration */}
<ExportImportSection />
{/* Version Info */} {/* Version Info */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6"> <div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<h2 className="mb-4 text-lg font-semibold text-white">Version Information</h2> <h2 className="mb-4 text-lg font-semibold text-white">Version Information</h2>
@@ -732,6 +739,143 @@ export default function SystemPage() {
); );
} }
/* ── Export / Import Configuration ───────────────────────────────── */
function ExportImportSection() {
const fileRef = useRef<HTMLInputElement>(null);
const [importing, setImporting] = useState(false);
const [importResult, setImportResult] = useState<{
status: string;
summary: Record<string, number>;
warnings: string[];
} | null>(null);
const [importError, setImportError] = useState<string | null>(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<HTMLInputElement>) => {
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 (
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<div className="flex items-center gap-2 mb-1">
<PackageOpen className="h-5 w-5 text-cyan-400" />
<h2 className="text-lg font-semibold text-white">Configuration Export / Import</h2>
</div>
<p className="mb-5 text-sm text-gray-400">
Export all platform configuration to a JSON file for backup or migration.
Import restores settings, webhooks, custom templates and users on a fresh instance.
</p>
{/* What is exported */}
<div className="mb-5 rounded-lg border border-gray-800 bg-gray-800/30 p-4">
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-gray-500">What is included</p>
<div className="grid gap-x-8 gap-y-1 text-xs text-gray-400 sm:grid-cols-2">
{[
["✅", "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]) => (
<div key={text} className="flex items-center gap-1.5">
<span>{icon}</span>
<span>{text}</span>
</div>
))}
</div>
</div>
{/* Actions */}
<div className="flex flex-wrap gap-3">
<button
onClick={handleExport}
className="flex items-center gap-2 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-medium text-white hover:bg-cyan-500 transition-colors"
>
<Download className="h-4 w-4" />
Export Configuration
</button>
<input ref={fileRef} type="file" accept=".json" onChange={handleImportFile} className="hidden" />
<button
onClick={() => fileRef.current?.click()}
disabled={importing}
className="flex items-center gap-2 rounded-lg border border-gray-700 bg-gray-800 px-4 py-2 text-sm font-medium text-gray-300 hover:bg-gray-700 disabled:opacity-50 transition-colors"
>
{importing ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
{importing ? "Importing…" : "Import Configuration"}
</button>
</div>
{/* Import result */}
{importResult && (
<div className="mt-4 rounded-lg border border-green-500/30 bg-green-500/5 p-4 space-y-2">
<p className="text-sm font-medium text-green-400 flex items-center gap-2">
<CheckCircle className="h-4 w-4" /> Import completed successfully
</p>
<div className="grid gap-2 text-xs text-gray-400 sm:grid-cols-3">
{Object.entries(importResult.summary).map(([key, val]) => (
<div key={key} className="flex items-center justify-between rounded bg-gray-800 px-2 py-1">
<span className="capitalize">{key.replace(/_/g, " ")}</span>
<span className="font-mono text-cyan-400">{val}</span>
</div>
))}
</div>
{importResult.warnings.length > 0 && (
<ul className="mt-2 space-y-0.5 text-[11px] text-amber-400/80 list-disc list-inside">
{importResult.warnings.map((w) => <li key={w}>{w}</li>)}
</ul>
)}
</div>
)}
{importError && (
<div className="mt-4 rounded-lg border border-red-500/30 bg-red-900/20 p-3 text-sm text-red-400">
{importError}
</div>
)}
</div>
);
}
/* ── Create Template Form (inline modal) ──────────────────────────── */ /* ── Create Template Form (inline modal) ──────────────────────────── */
function CreateTemplateForm({ function CreateTemplateForm({