refactor: 101.79.17.164 → zioinfo.co.kr 전체 도메인 변환 + Manager UI 배포
- 37개 파일 IP → zioinfo.co.kr 치환 (소스/매뉴얼/설정/하네스) - Manager DrConsole/NetworkConsole/CsapConsole 빌드 + /var/www/manager/ 배포 - 테스트: Manager HTTP 200, ITSM 신규 API 7개 전체 200 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
9031876278
commit
75d92f2b90
91
Jenkinsfile
vendored
Normal file
91
Jenkinsfile
vendored
Normal file
@ -0,0 +1,91 @@
|
||||
pipeline {
|
||||
agent any
|
||||
|
||||
environment {
|
||||
APP_DIR = '/opt/guardia/app'
|
||||
VENV = '/opt/guardia/venv'
|
||||
SERVICE = 'guardia'
|
||||
GITEA_URL = 'http://localhost:3000/zio/guardia-itsm.git'
|
||||
}
|
||||
|
||||
options {
|
||||
buildDiscarder(logRotator(numToKeepStr: '5'))
|
||||
timeout(time: 15, unit: 'MINUTES')
|
||||
}
|
||||
|
||||
stages {
|
||||
stage('Checkout') {
|
||||
steps {
|
||||
echo "체크아웃: ${env.GIT_BRANCH ?: 'main'}"
|
||||
checkout scm
|
||||
}
|
||||
}
|
||||
|
||||
stage('Python Lint') {
|
||||
steps {
|
||||
sh '''
|
||||
echo "=== Python 문법 검사 ==="
|
||||
python3 -m py_compile main.py models.py database.py
|
||||
find routers/ -name "*.py" -exec python3 -m py_compile {} \\;
|
||||
find core/ -name "*.py" -exec python3 -m py_compile {} \\;
|
||||
echo "문법 검사 통과"
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
stage('Install Dependencies') {
|
||||
steps {
|
||||
sh '${VENV}/bin/pip install -r requirements.txt -q && echo "의존성 OK"'
|
||||
}
|
||||
}
|
||||
|
||||
stage('Health Check') {
|
||||
steps {
|
||||
sh '''
|
||||
if systemctl is-active ${SERVICE} >/dev/null 2>&1; then
|
||||
HTTP=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8001/docs)
|
||||
echo "현재 서비스 HTTP: $HTTP"
|
||||
else
|
||||
echo "서비스 미실행"
|
||||
fi
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
stage('Deploy') {
|
||||
when { branch 'main' }
|
||||
steps {
|
||||
sh '''
|
||||
echo "=== GUARDiA ITSM 배포 ==="
|
||||
# 백업
|
||||
BACKUP=/opt/guardia/backups/$(date +%Y%m%d_%H%M%S)
|
||||
mkdir -p $BACKUP
|
||||
cp -r ${APP_DIR}/*.py ${APP_DIR}/routers ${APP_DIR}/core $BACKUP/ 2>/dev/null || true
|
||||
|
||||
# 파일 복사
|
||||
rsync -av --exclude="__pycache__" --exclude="test_*.py" \\
|
||||
--exclude="*.db" --exclude=".git" \\
|
||||
./ ${APP_DIR}/
|
||||
|
||||
# 패키지 업데이트
|
||||
${VENV}/bin/pip install -r requirements.txt -q
|
||||
|
||||
# 서비스 재시작
|
||||
systemctl restart ${SERVICE}
|
||||
sleep 5
|
||||
|
||||
# 헬스체크
|
||||
HTTP=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8001/docs)
|
||||
echo "배포 후 HTTP: $HTTP"
|
||||
[ "$HTTP" = "200" ] && echo "배포 성공" || (echo "배포 실패"; exit 1)
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
post {
|
||||
success { echo "✅ GUARDiA 배포 성공: ${currentBuild.displayName}" }
|
||||
failure { echo "❌ GUARDiA 배포 실패: ${currentBuild.displayName}" }
|
||||
always { cleanWs(cleanWhenNotBuilt: false, cleanWhenSuccess: false) }
|
||||
}
|
||||
}
|
||||
94
core/external_security.py
Normal file
94
core/external_security.py
Normal file
@ -0,0 +1,94 @@
|
||||
"""
|
||||
개방망 외부 API 보안 — API Key 발급·검증·감사
|
||||
- API Key: sha256 해시 저장, 평문은 발급 시 1회만 노출
|
||||
- 권한 스코프: read | write | admin | webhook
|
||||
- IP 화이트리스트 (선택)
|
||||
- 요청별 감사 로깅
|
||||
"""
|
||||
import hashlib
|
||||
import secrets
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Depends, Header, HTTPException, Request, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database import get_db
|
||||
from models import APIKey, AuditLog
|
||||
|
||||
|
||||
# ── API Key 생성 ──────────────────────────────────────────────────────────────
|
||||
|
||||
def generate_api_key() -> tuple[str, str]:
|
||||
"""평문 키와 SHA-256 해시를 반환. 평문은 1회만 노출."""
|
||||
plain = "grd_" + secrets.token_urlsafe(32)
|
||||
hashed = hashlib.sha256(plain.encode()).hexdigest()
|
||||
return plain, hashed
|
||||
|
||||
|
||||
def hash_key(plain: str) -> str:
|
||||
return hashlib.sha256(plain.encode()).hexdigest()
|
||||
|
||||
|
||||
# ── API Key 검증 Dependency ───────────────────────────────────────────────────
|
||||
|
||||
async def verify_api_key(
|
||||
request: Request,
|
||||
x_api_key: Optional[str] = Header(None, alias="X-API-Key"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> "APIKey":
|
||||
"""
|
||||
외부 API 호출용 API Key 인증.
|
||||
헤더: X-API-Key: grd_xxxxx
|
||||
"""
|
||||
if not x_api_key:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="X-API-Key 헤더가 필요합니다.",
|
||||
headers={"WWW-Authenticate": "ApiKey"},
|
||||
)
|
||||
|
||||
key_hash = hash_key(x_api_key)
|
||||
row = await db.execute(
|
||||
select(APIKey).where(APIKey.key_hash == key_hash, APIKey.is_active == True)
|
||||
)
|
||||
api_key = row.scalar_one_or_none()
|
||||
|
||||
if not api_key:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="유효하지 않은 API Key입니다.")
|
||||
|
||||
# 만료 확인
|
||||
if api_key.expires_at and api_key.expires_at < datetime.now(timezone.utc).replace(tzinfo=None):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="만료된 API Key입니다.")
|
||||
|
||||
# IP 화이트리스트 확인
|
||||
if api_key.allowed_ips:
|
||||
client_ip = request.client.host
|
||||
allowed = [ip.strip() for ip in api_key.allowed_ips.split(",")]
|
||||
if client_ip not in allowed:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"허용되지 않은 IP입니다.",
|
||||
)
|
||||
|
||||
# 사용 횟수 갱신
|
||||
api_key.use_count = (api_key.use_count or 0) + 1
|
||||
api_key.last_used_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
return api_key
|
||||
|
||||
|
||||
def require_scope(scope: str):
|
||||
"""특정 스코프(read/write/admin/webhook)를 요구하는 Dependency 팩토리."""
|
||||
async def _check(api_key: "APIKey" = Depends(verify_api_key)):
|
||||
scopes = (api_key.scopes or "").split(",")
|
||||
if scope not in scopes and "admin" not in scopes:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"'{scope}' 권한이 필요합니다.",
|
||||
)
|
||||
return api_key
|
||||
return _check
|
||||
48
deploy/guardia_deploy.sh
Normal file
48
deploy/guardia_deploy.sh
Normal file
@ -0,0 +1,48 @@
|
||||
#!/bin/bash
|
||||
# GUARDiA ITSM 자동 배포 스크립트 (Deploy Webhook / CI 트리거 시 실행)
|
||||
# 경로: /opt/guardia/app/deploy/guardia_deploy.sh
|
||||
|
||||
set -euo pipefail
|
||||
LOG=/opt/guardia/logs/deploy.log
|
||||
APP=/opt/guardia/app
|
||||
REPO=http://zio:Zio%40Admin2026%21@localhost:3000/zio/guardia-itsm.git
|
||||
SRC=/opt/guardia/src
|
||||
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] === GUARDiA 배포 시작 ===" >> $LOG
|
||||
|
||||
# 1. 소스 갱신
|
||||
if [ ! -d "$SRC/.git" ]; then
|
||||
echo "[$(date)] git clone..." >> $LOG
|
||||
git clone $REPO $SRC
|
||||
else
|
||||
echo "[$(date)] git pull..." >> $LOG
|
||||
cd $SRC && git pull origin main
|
||||
fi
|
||||
echo "[$(date)] 소스 갱신 완료" >> $LOG
|
||||
|
||||
# 2. 새 파일 복사 (test_, __pycache__ 제외)
|
||||
rsync -av --exclude="__pycache__" --exclude="test_*.py" \
|
||||
--exclude="*.db" --exclude="*.bak" --exclude=".git" \
|
||||
$SRC/ $APP/ >> $LOG 2>&1
|
||||
echo "[$(date)] 파일 복사 완료" >> $LOG
|
||||
|
||||
# 3. 패키지 업데이트
|
||||
if [ -f "$SRC/requirements.txt" ]; then
|
||||
/opt/guardia/venv/bin/pip install -r $SRC/requirements.txt -q >> $LOG 2>&1
|
||||
echo "[$(date)] 패키지 업데이트 완료" >> $LOG
|
||||
fi
|
||||
|
||||
# 4. 서비스 재시작
|
||||
systemctl restart guardia
|
||||
sleep 5
|
||||
|
||||
# 5. 헬스체크
|
||||
HTTP=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8001/docs 2>/dev/null)
|
||||
if [ "$HTTP" = "200" ]; then
|
||||
echo "[$(date)] 배포 성공 (HTTP $HTTP)" >> $LOG
|
||||
else
|
||||
echo "[$(date)] 배포 실패 (HTTP $HTTP)" >> $LOG
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[$(date)] === GUARDiA 배포 완료 ===" >> $LOG
|
||||
321
routers/export_import.py
Normal file
321
routers/export_import.py
Normal file
@ -0,0 +1,321 @@
|
||||
"""
|
||||
폐쇄망 ↔ 개방망 데이터 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)}건",
|
||||
}
|
||||
261
routers/external_api.py
Normal file
261
routers/external_api.py
Normal file
@ -0,0 +1,261 @@
|
||||
"""
|
||||
개방망 외부 API 라우터
|
||||
- /api/external/keys : API Key CRUD (관리자 JWT 인증)
|
||||
- /api/external/health : 공개 헬스체크
|
||||
- /api/external/webhook : 외부 메신저 웹훅 수신
|
||||
- /api/external/sr : 외부에서 SR 조회·등록 (API Key)
|
||||
- /api/external/notify : 외부 알림 전송 (API Key)
|
||||
- /api/external/status : 시스템 상태 공개 요약
|
||||
"""
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Header, HTTPException, Request, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.external_security import generate_api_key, hash_key, require_scope, verify_api_key
|
||||
from core.auth import get_current_user
|
||||
from database import get_db
|
||||
from models import APIKey, SRRequest as ServiceRequest, User
|
||||
|
||||
router = APIRouter(prefix="/api/external", tags=["External API (개방망)"])
|
||||
|
||||
|
||||
# ── 공개 엔드포인트 ───────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/health", summary="헬스체크 (인증 불필요)")
|
||||
async def health():
|
||||
return {
|
||||
"status": "ok",
|
||||
"service": "GUARDiA ITSM",
|
||||
"version": "2.0.0",
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/status", summary="시스템 공개 상태")
|
||||
async def public_status(db: AsyncSession = Depends(get_db)):
|
||||
"""인증 없이 시스템 가동 상태를 반환합니다."""
|
||||
try:
|
||||
sr_count = (await db.execute(
|
||||
select(ServiceRequest).where(ServiceRequest.status != "CLOSED")
|
||||
)).scalars().all()
|
||||
open_sr = len(sr_count)
|
||||
except Exception:
|
||||
open_sr = -1
|
||||
|
||||
return {
|
||||
"status": "operational",
|
||||
"open_service_requests": open_sr,
|
||||
"api_version": "v2",
|
||||
"docs": "/docs",
|
||||
}
|
||||
|
||||
|
||||
# ── API Key 관리 (관리자 전용) ────────────────────────────────────────────────
|
||||
|
||||
class APIKeyCreate(BaseModel):
|
||||
name: str
|
||||
scopes: str = "read" # read, write, admin, webhook (쉼표 구분)
|
||||
allowed_ips: Optional[str] = None # "1.2.3.4,5.6.7.8" (빈 문자열 = 전체 허용)
|
||||
expires_days: Optional[int] = 365
|
||||
|
||||
|
||||
@router.get("/keys", summary="API Key 목록 (관리자)")
|
||||
async def list_keys(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
if current_user.role not in ("admin", "pm"):
|
||||
raise HTTPException(status_code=403, detail="관리자 권한이 필요합니다.")
|
||||
rows = (await db.execute(select(APIKey).order_by(APIKey.created_at.desc()))).scalars().all()
|
||||
return [
|
||||
{
|
||||
"id": k.id,
|
||||
"name": k.name,
|
||||
"scopes": k.scopes,
|
||||
"allowed_ips": k.allowed_ips,
|
||||
"is_active": k.is_active,
|
||||
"use_count": k.use_count,
|
||||
"last_used_at": k.last_used_at,
|
||||
"expires_at": k.expires_at,
|
||||
"created_at": k.created_at,
|
||||
}
|
||||
for k in rows
|
||||
]
|
||||
|
||||
|
||||
@router.post("/keys", summary="API Key 발급 (관리자)", status_code=201)
|
||||
async def create_key(
|
||||
body: APIKeyCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
if current_user.role not in ("admin", "pm"):
|
||||
raise HTTPException(status_code=403, detail="관리자 권한이 필요합니다.")
|
||||
|
||||
plain, key_hash = generate_api_key()
|
||||
expires_at = None
|
||||
if body.expires_days:
|
||||
expires_at = datetime.utcnow() + timedelta(days=body.expires_days)
|
||||
|
||||
api_key = APIKey(
|
||||
name=body.name,
|
||||
key_hash=key_hash,
|
||||
scopes=body.scopes,
|
||||
allowed_ips=body.allowed_ips or "",
|
||||
expires_at=expires_at,
|
||||
created_by=current_user.username,
|
||||
is_active=True,
|
||||
use_count=0,
|
||||
)
|
||||
db.add(api_key)
|
||||
await db.commit()
|
||||
await db.refresh(api_key)
|
||||
|
||||
return {
|
||||
"id": api_key.id,
|
||||
"name": api_key.name,
|
||||
"api_key": plain, # ← 단 1회만 노출
|
||||
"scopes": api_key.scopes,
|
||||
"expires_at": api_key.expires_at,
|
||||
"warning": "이 키는 다시 조회할 수 없습니다. 안전한 곳에 저장하세요.",
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/keys/{key_id}", summary="API Key 비활성화")
|
||||
async def revoke_key(
|
||||
key_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
if current_user.role not in ("admin", "pm"):
|
||||
raise HTTPException(status_code=403, detail="관리자 권한이 필요합니다.")
|
||||
row = await db.get(APIKey, key_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="키를 찾을 수 없습니다.")
|
||||
row.is_active = False
|
||||
await db.commit()
|
||||
return {"message": f"API Key '{row.name}' 비활성화 완료"}
|
||||
|
||||
|
||||
# ── 외부 메신저 웹훅 ─────────────────────────────────────────────────────────
|
||||
|
||||
WEBHOOK_SECRET = os.environ.get("GUARDIA_WEBHOOK_SECRET", "guardia-webhook-2026")
|
||||
|
||||
|
||||
def _verify_hmac(body: bytes, signature: str) -> bool:
|
||||
expected = hmac.new(WEBHOOK_SECRET.encode(), body, hashlib.sha256).hexdigest()
|
||||
return hmac.compare_digest(expected, signature.removeprefix("sha256="))
|
||||
|
||||
|
||||
@router.post("/webhook", summary="외부 메신저 웹훅 수신")
|
||||
async def receive_webhook(
|
||||
request: Request,
|
||||
x_guardia_signature: Optional[str] = Header(None, alias="X-GUARDiA-Signature"),
|
||||
x_source: str = Header("unknown", alias="X-Source"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
카카오워크/네이버웍스/Slack/Teams 등 외부 메신저에서
|
||||
GUARDiA 명령을 전달받는 엔드포인트.
|
||||
|
||||
Headers:
|
||||
X-GUARDiA-Signature: sha256=<hmac> (선택, 권장)
|
||||
X-Source: kakaotalk | naverworks | slack | teams | custom
|
||||
"""
|
||||
body = await request.body()
|
||||
|
||||
# HMAC 검증 (서명이 있는 경우만 강제)
|
||||
if x_guardia_signature:
|
||||
if not _verify_hmac(body, x_guardia_signature):
|
||||
raise HTTPException(status_code=401, detail="웹훅 서명 검증 실패")
|
||||
|
||||
try:
|
||||
payload = json.loads(body)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="JSON 파싱 오류")
|
||||
|
||||
command = payload.get("command") or payload.get("text") or payload.get("message", "")
|
||||
user_id = payload.get("user_id") or payload.get("sender") or "external"
|
||||
|
||||
# AI 자연어 명령 파싱 후 처리
|
||||
try:
|
||||
from core.nlu import parse_command
|
||||
parsed = await parse_command(command, db)
|
||||
action = parsed.get("action", "unknown")
|
||||
except Exception:
|
||||
parsed = {"action": "echo", "raw": command}
|
||||
action = "echo"
|
||||
|
||||
return {
|
||||
"received": True,
|
||||
"source": x_source,
|
||||
"command": command[:200],
|
||||
"action": action,
|
||||
"message": f"[GUARDiA] 명령 수신됨: {command[:100]}",
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
|
||||
# ── 외부 SR 인터페이스 (API Key 인증) ────────────────────────────────────────
|
||||
|
||||
class ExternalSRCreate(BaseModel):
|
||||
title: str
|
||||
description: str
|
||||
priority: str = "MEDIUM" # LOW | MEDIUM | HIGH | CRITICAL
|
||||
requester_name: str
|
||||
requester_email: str = ""
|
||||
|
||||
|
||||
@router.get("/sr", summary="SR 목록 조회 (API Key read 권한)")
|
||||
async def external_list_sr(
|
||||
status_filter: Optional[str] = None,
|
||||
limit: int = 20,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
api_key=Depends(require_scope("read")),
|
||||
):
|
||||
q = select(ServiceRequest).limit(limit).order_by(ServiceRequest.created_at.desc())
|
||||
if status_filter:
|
||||
q = q.where(ServiceRequest.status == status_filter)
|
||||
rows = (await db.execute(q)).scalars().all()
|
||||
return [
|
||||
{
|
||||
"id": r.id,
|
||||
"sr_id": r.sr_id,
|
||||
"title": r.title,
|
||||
"status": r.status,
|
||||
"priority": r.priority,
|
||||
"created_at": r.created_at,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
@router.post("/sr", summary="SR 등록 (API Key write 권한)", status_code=201)
|
||||
async def external_create_sr(
|
||||
body: ExternalSRCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
api_key=Depends(require_scope("write")),
|
||||
):
|
||||
import secrets as _s
|
||||
sr_id = f"EXT-{_s.token_hex(4).upper()}"
|
||||
sr = ServiceRequest(
|
||||
sr_id=sr_id,
|
||||
title=body.title,
|
||||
description=body.description,
|
||||
priority=body.priority,
|
||||
status="RECEIVED",
|
||||
requested_by=f"{body.requester_name} ({body.requester_email})",
|
||||
sr_type="OTHER",
|
||||
)
|
||||
db.add(sr)
|
||||
await db.commit()
|
||||
await db.refresh(sr)
|
||||
return {"id": sr.id, "sr_id": sr.sr_id, "title": sr.title, "status": sr.status, "message": "SR이 등록되었습니다."}
|
||||
Loading…
Reference in New Issue
Block a user