- itsm/ -> workspace/guardia-itsm/ - manager/ -> workspace/guardia-manager/ - app/ -> workspace/guardia-messenger/ - manual/ -> workspace/guardia-docs/ workspace/zioinfo-web/ unchanged. git mv preserves full commit history. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
322 lines
12 KiB
Python
322 lines
12 KiB
Python
"""
|
|
폐쇄망 ↔ 개방망 데이터 Export / Import 인터페이스
|
|
|
|
Export (폐쇄망 → 파일):
|
|
GET /api/export/bundle — 전체 데이터 번들 (JSON ZIP)
|
|
GET /api/export/sr — SR 목록 JSON
|
|
GET /api/export/cmdb — CMDB 서버 자산 JSON
|
|
GET /api/export/kb — 지식베이스 JSON
|
|
GET /api/export/audit — 감사 로그 JSON
|
|
|
|
Import (파일 → 개방망 GUARDiA):
|
|
POST /api/import/bundle — 번들 ZIP 업로드 및 병합
|
|
POST /api/import/sr — SR JSON 업로드
|
|
POST /api/import/cmdb — CMDB JSON 업로드
|
|
|
|
보안:
|
|
- Admin JWT 인증 필수
|
|
- 민감 필드(ip_addr, os_pw_enc, ssh_user) Export 시 마스킹
|
|
- 번들에 HMAC-SHA256 서명 포함
|
|
"""
|
|
import hashlib, hmac, io, json, os, zipfile
|
|
from datetime import datetime
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
|
|
from fastapi.responses import StreamingResponse
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from core.auth import get_current_user, require_admin_role
|
|
from database import get_db
|
|
from models import (
|
|
SRRequest, Institution, Server, AuditLog, User,
|
|
)
|
|
|
|
router = APIRouter(prefix="/api/export-import", tags=["Export/Import (폐쇄망 연동)"])
|
|
|
|
BUNDLE_SECRET = os.environ.get("GUARDIA_BUNDLE_SECRET", "guardia-bundle-hmac-2026")
|
|
SENSITIVE_FIELDS = {"ip_addr", "os_pw_enc", "ssh_user", "ssh_pw"}
|
|
|
|
|
|
# ── 유틸 ──────────────────────────────────────────────────────────────────────
|
|
|
|
def _mask_row(row: dict) -> dict:
|
|
return {k: ("****" if k in SENSITIVE_FIELDS else v) for k, v in row.items()}
|
|
|
|
def _sign_bundle(data: bytes) -> str:
|
|
return hmac.new(BUNDLE_SECRET.encode(), data, hashlib.sha256).hexdigest()
|
|
|
|
def _row_to_dict(obj) -> dict:
|
|
from datetime import date
|
|
result = {}
|
|
for col in obj.__table__.columns:
|
|
val = getattr(obj, col.name)
|
|
if isinstance(val, datetime):
|
|
val = val.isoformat()
|
|
elif isinstance(val, date):
|
|
val = val.isoformat()
|
|
elif val is not None and not isinstance(val, (str, int, float, bool, dict, list)):
|
|
val = str(val)
|
|
result[col.name] = val
|
|
return _mask_row(result)
|
|
|
|
|
|
# ── Export 엔드포인트 ─────────────────────────────────────────────────────────
|
|
|
|
@router.get("/export/sr", summary="SR 목록 Export (JSON)")
|
|
async def export_sr(
|
|
limit: int = Query(default=1000, le=5000),
|
|
since: Optional[str] = Query(default=None, description="ISO 날짜, 예: 2026-01-01"),
|
|
db: AsyncSession = Depends(get_db),
|
|
_user=Depends(require_admin_role),
|
|
):
|
|
q = select(SRRequest).order_by(SRRequest.created_at.desc()).limit(limit)
|
|
if since:
|
|
try:
|
|
dt = datetime.fromisoformat(since).replace(tzinfo=None)
|
|
q = q.where(SRRequest.created_at >= dt)
|
|
except ValueError:
|
|
raise HTTPException(status_code=400, detail="since 날짜 형식 오류 (ISO 8601)")
|
|
rows = (await db.execute(q)).scalars().all()
|
|
data = [_row_to_dict(r) for r in rows]
|
|
return {
|
|
"exported_at": datetime.utcnow().isoformat(),
|
|
"count": len(data),
|
|
"type": "sr",
|
|
"data": data,
|
|
}
|
|
|
|
|
|
@router.get("/export/cmdb", summary="CMDB 서버 자산 Export (JSON)")
|
|
async def export_cmdb(
|
|
db: AsyncSession = Depends(get_db),
|
|
_user=Depends(require_admin_role),
|
|
):
|
|
rows = (await db.execute(select(Server))).scalars().all()
|
|
data = [_row_to_dict(r) for r in rows]
|
|
return {
|
|
"exported_at": datetime.utcnow().isoformat(),
|
|
"count": len(data),
|
|
"type": "cmdb",
|
|
"data": data,
|
|
}
|
|
|
|
|
|
@router.get("/export/institutions", summary="기관 목록 Export (JSON)")
|
|
async def export_institutions(
|
|
db: AsyncSession = Depends(get_db),
|
|
_user=Depends(require_admin_role),
|
|
):
|
|
rows = (await db.execute(select(Institution))).scalars().all()
|
|
data = [_row_to_dict(r) for r in rows]
|
|
return {
|
|
"exported_at": datetime.utcnow().isoformat(),
|
|
"count": len(data),
|
|
"type": "institutions",
|
|
"data": data,
|
|
}
|
|
|
|
|
|
@router.get("/export/audit", summary="감사 로그 Export (JSON)")
|
|
async def export_audit(
|
|
limit: int = Query(default=500, le=2000),
|
|
db: AsyncSession = Depends(get_db),
|
|
_user=Depends(require_admin_role),
|
|
):
|
|
rows = (await db.execute(
|
|
select(AuditLog).order_by(AuditLog.created_at.desc()).limit(limit)
|
|
)).scalars().all()
|
|
data = [_row_to_dict(r) for r in rows]
|
|
return {
|
|
"exported_at": datetime.utcnow().isoformat(),
|
|
"count": len(data),
|
|
"type": "audit",
|
|
"data": data,
|
|
}
|
|
|
|
|
|
@router.get("/export/bundle", summary="전체 번들 Export (ZIP 다운로드)")
|
|
async def export_bundle(
|
|
db: AsyncSession = Depends(get_db),
|
|
user=Depends(require_admin_role),
|
|
):
|
|
"""
|
|
SR, CMDB, 기관, 감사로그를 하나의 ZIP으로 패키징합니다.
|
|
- 민감 필드(IP, 비밀번호)는 마스킹
|
|
- 번들에 HMAC-SHA256 서명 포함
|
|
- Content-Type: application/zip
|
|
"""
|
|
bundle: dict[str, dict] = {}
|
|
|
|
for name, q in [
|
|
("sr", select(SRRequest).order_by(SRRequest.created_at.desc()).limit(2000)),
|
|
("cmdb", select(Server)),
|
|
("institutions", select(Institution)),
|
|
("audit", select(AuditLog).order_by(AuditLog.created_at.desc()).limit(1000)),
|
|
]:
|
|
rows = (await db.execute(q)).scalars().all()
|
|
bundle[name] = {
|
|
"exported_at": datetime.utcnow().isoformat(),
|
|
"count": len(rows),
|
|
"type": name,
|
|
"data": [_row_to_dict(r) for r in rows],
|
|
}
|
|
|
|
manifest = {
|
|
"version": "1.0",
|
|
"source": "GUARDiA ITSM (폐쇄망)",
|
|
"exported_at": datetime.utcnow().isoformat(),
|
|
"exported_by": user.username,
|
|
"contents": list(bundle.keys()),
|
|
"signature": "",
|
|
}
|
|
bundle_json = json.dumps(bundle, ensure_ascii=False, indent=2).encode()
|
|
manifest["signature"] = _sign_bundle(bundle_json)
|
|
|
|
# ZIP 생성
|
|
buf = io.BytesIO()
|
|
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
zf.writestr("manifest.json", json.dumps(manifest, ensure_ascii=False, indent=2))
|
|
zf.writestr("bundle.json", bundle_json)
|
|
for name, section in bundle.items():
|
|
zf.writestr(f"{name}.json", json.dumps(section, ensure_ascii=False, indent=2))
|
|
buf.seek(0)
|
|
|
|
filename = f"guardia_export_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}.zip"
|
|
return StreamingResponse(
|
|
buf,
|
|
media_type="application/zip",
|
|
headers={"Content-Disposition": f"attachment; filename={filename}"},
|
|
)
|
|
|
|
|
|
# ── Import 엔드포인트 ─────────────────────────────────────────────────────────
|
|
|
|
@router.post("/import/bundle", summary="번들 ZIP Import (폐쇄망 데이터 업로드)")
|
|
async def import_bundle(
|
|
file: UploadFile = File(..., description="export_bundle에서 생성된 ZIP 파일"),
|
|
dry_run: bool = Query(default=True, description="True: 검증만 (실제 저장 안 함)"),
|
|
db: AsyncSession = Depends(get_db),
|
|
_user=Depends(require_admin_role),
|
|
):
|
|
"""
|
|
폐쇄망에서 Export한 번들 ZIP을 업로드합니다.
|
|
- HMAC 서명 검증
|
|
- dry_run=True (기본): 데이터 검증만 수행 (DB 저장 없음)
|
|
- dry_run=False: 실제 병합 (중복 sr_id는 SKIP)
|
|
"""
|
|
if not file.filename.endswith(".zip"):
|
|
raise HTTPException(status_code=400, detail="ZIP 파일만 업로드 가능합니다.")
|
|
|
|
content = await file.read()
|
|
if len(content) > 50 * 1024 * 1024: # 50MB 제한
|
|
raise HTTPException(status_code=413, detail="파일 크기 50MB 초과")
|
|
|
|
try:
|
|
buf = io.BytesIO(content)
|
|
with zipfile.ZipFile(buf, "r") as zf:
|
|
names = zf.namelist()
|
|
if "manifest.json" not in names or "bundle.json" not in names:
|
|
raise HTTPException(status_code=400, detail="유효하지 않은 번들 파일입니다.")
|
|
|
|
manifest = json.loads(zf.read("manifest.json"))
|
|
bundle_json = zf.read("bundle.json")
|
|
bundle = json.loads(bundle_json)
|
|
except (zipfile.BadZipFile, json.JSONDecodeError) as e:
|
|
raise HTTPException(status_code=400, detail=f"파일 파싱 오류: {e}")
|
|
|
|
# HMAC 검증
|
|
expected_sig = _sign_bundle(bundle_json)
|
|
if manifest.get("signature") != expected_sig:
|
|
raise HTTPException(status_code=400, detail="번들 서명 검증 실패 (위변조 의심)")
|
|
|
|
stats = {
|
|
"manifest": {
|
|
"exported_at": manifest.get("exported_at"),
|
|
"source": manifest.get("source"),
|
|
"exported_by": manifest.get("exported_by"),
|
|
},
|
|
"sections": {},
|
|
}
|
|
|
|
for section_name, section in bundle.items():
|
|
count = section.get("count", 0)
|
|
stats["sections"][section_name] = {
|
|
"count": count, "status": "dry_run" if dry_run else "imported",
|
|
}
|
|
|
|
if not dry_run:
|
|
# SR Import (중복 sr_id SKIP)
|
|
sr_section = bundle.get("sr", {})
|
|
imported = 0
|
|
for row in sr_section.get("data", []):
|
|
existing = await db.execute(
|
|
select(SRRequest).where(SRRequest.sr_id == row.get("sr_id"))
|
|
)
|
|
if existing.scalars().first():
|
|
continue
|
|
sr = SRRequest(
|
|
sr_id = row.get("sr_id", f"IMP-{hashlib.md5(str(row).encode()).hexdigest()[:8].upper()}"),
|
|
title = row.get("title", "(imported)"),
|
|
description = row.get("description", ""),
|
|
status = row.get("status", "RECEIVED"),
|
|
priority = row.get("priority", "MEDIUM"),
|
|
sr_type = row.get("sr_type", "OTHER"),
|
|
requested_by= row.get("requested_by", "imported"),
|
|
)
|
|
db.add(sr); imported += 1
|
|
await db.commit()
|
|
stats["sections"]["sr"]["imported"] = imported
|
|
|
|
return {
|
|
"status": "dry_run" if dry_run else "imported",
|
|
"message": "검증 완료 (dry_run)" if dry_run else f"Import 완료",
|
|
**stats,
|
|
}
|
|
|
|
|
|
@router.post("/import/sr", summary="SR JSON Import")
|
|
async def import_sr(
|
|
file: UploadFile = File(..., description="export/sr에서 내보낸 JSON 파일"),
|
|
dry_run: bool = Query(default=True),
|
|
db: AsyncSession = Depends(get_db),
|
|
_user=Depends(require_admin_role),
|
|
):
|
|
content = await file.read()
|
|
try:
|
|
data = json.loads(content)
|
|
records = data.get("data", data) if isinstance(data, dict) else data
|
|
except json.JSONDecodeError as e:
|
|
raise HTTPException(status_code=400, detail=f"JSON 파싱 오류: {e}")
|
|
|
|
if not isinstance(records, list):
|
|
raise HTTPException(status_code=400, detail="data 배열이 필요합니다.")
|
|
|
|
imported = 0
|
|
if not dry_run:
|
|
for row in records:
|
|
existing = await db.execute(
|
|
select(SRRequest).where(SRRequest.sr_id == row.get("sr_id"))
|
|
)
|
|
if existing.scalars().first():
|
|
continue
|
|
db.add(SRRequest(
|
|
sr_id = row.get("sr_id", f"IMP-{imported:06d}"),
|
|
title = row.get("title", "(imported)"),
|
|
description = row.get("description", ""),
|
|
status = row.get("status", "RECEIVED"),
|
|
priority = row.get("priority", "MEDIUM"),
|
|
sr_type = row.get("sr_type", "OTHER"),
|
|
requested_by= row.get("requested_by", "imported"),
|
|
))
|
|
imported += 1
|
|
await db.commit()
|
|
|
|
return {
|
|
"status": "dry_run" if dry_run else "imported",
|
|
"total": len(records),
|
|
"imported": imported,
|
|
"message": f"SR {'검증' if dry_run else '가져오기'} 완료: {len(records)}건",
|
|
}
|