diff --git a/main.py b/main.py index d7393fd..b1c3c60 100644 --- a/main.py +++ b/main.py @@ -537,7 +537,17 @@ app.include_router(stats_router.router) # #93~#97 통계·보고 app.include_router(cicd.router) # #99 #100 CI/CD 빌드 상태 from routers import mobile2_ext -app.include_router(mobile2_ext.router) # #101~#200 2세대 보조 엔드포인트 +app.include_router(mobile2_ext.router) # #101~#200 2세대 보조 (mobile2 prefix) +app.include_router(mobile2_ext.ai_router) # /api/ai-insights/* +app.include_router(mobile2_ext.greenops_router) # /api/greenops/* +app.include_router(mobile2_ext.soc2_router) # /api/ai-soc 확장 +app.include_router(mobile2_ext.cmdb2_router) # /api/cmdb 확장 (ssl·eol·maintenance·warranty) +app.include_router(mobile2_ext.cloud_router) # /api/cloud/vms +app.include_router(mobile2_ext.kg_router) # /api/knowledge-graph/* +app.include_router(mobile2_ext.policy_router) # /api/policy/* +app.include_router(mobile2_ext.citizen_router) # /api/citizen/* +app.include_router(mobile2_ext.pub_router) # /api/public-sector/* +app.include_router(mobile2_ext.esign_router) # /api/approvals (전자서명 확장) # ── 개방망 보안 헤더 미들웨어 ──────────────────────────────────────────────── diff --git a/models.py b/models.py index 880055e..fc1cd68 100644 --- a/models.py +++ b/models.py @@ -7153,123 +7153,3 @@ class TmuxCommand(Base): command = Column(Text) sent_by = Column(String(100)) created_at = Column(DateTime, default=func.now()) - - -# ════════════════════════════════════════════════════════════════════════════════ -# ── 모바일 100기능 백엔드 모델 (Mobile API) ───────────────────────────────────── -# ════════════════════════════════════════════════════════════════════════════════ - -class AlertRule(Base): - """알림 규칙 — 서버/서비스/SR 지표 임계치 기반 노코드 알림 정의 (#45).""" - __tablename__ = "tb_alert_rule" - - id = Column(Integer, primary_key=True, index=True) - tenant_id = Column(String(50), nullable=False, index=True) - target_type = Column(String(20), nullable=False) # server | service | sr - target_id = Column(String(100)) - metric = Column(String(30), nullable=False) # cpu | memory | disk | sla - threshold = Column(Float, nullable=False) - operator = Column(String(5), default=">") # > | < | = - channel = Column(String(20), default="inapp") # push | inapp | sms - enabled = Column(Boolean, default=True) - created_by = Column(String(100)) - created_at = Column(DateTime, default=func.now()) - updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) - - -class InventoryPart(Base): - """부품 재고 — 현장 서비스 교체 부품 관리 (#62).""" - __tablename__ = "tb_inventory_part" - - id = Column(Integer, primary_key=True, index=True) - tenant_id = Column(String(50), nullable=False, index=True) - name = Column(String(200), nullable=False) - model = Column(String(100)) - quantity = Column(Integer, default=0) - min_quantity = Column(Integer, default=1) - location = Column(String(200)) - created_at = Column(DateTime, default=func.now()) - updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) - - -class SRChatMessage(Base): - """SR 채팅 메시지 — SR별 실시간 협업 대화 (#98).""" - __tablename__ = "tb_sr_chat_message" - - id = Column(Integer, primary_key=True, index=True) - task_id = Column(String(30), nullable=False, index=True) # SRRequest.sr_id - sender_id = Column(String(100), nullable=False) - content = Column(Text, nullable=False) - msg_type = Column(String(20), default="text") # text | image | sr_update - read_by = Column(Text) # JSON list of usernames who read - created_at = Column(DateTime, default=func.now(), index=True) - - -class SRSubscription(Base): - """SR 구독/팔로우 — 사용자가 SR 변경 알림을 받도록 구독 (#14).""" - __tablename__ = "tb_sr_subscription" - - id = Column(Integer, primary_key=True, index=True) - task_id = Column(String(30), nullable=False, index=True) # SRRequest.sr_id - username = Column(String(100), nullable=False, index=True) - created_at = Column(DateTime, default=func.now()) - - -class SRRating(Base): - """SR 완료 만족도 평가 (#53).""" - __tablename__ = "tb_sr_rating" - - id = Column(Integer, primary_key=True, index=True) - task_id = Column(String(30), nullable=False, index=True) - rater = Column(String(100), nullable=False) - score = Column(Integer, nullable=False) # 1~5 - comment = Column(Text) - created_at = Column(DateTime, default=func.now()) - - -class SRSignature(Base): - """현장 전자서명 — 작업 완료 현장 확인 서명 (#70).""" - __tablename__ = "tb_sr_signature" - - id = Column(Integer, primary_key=True, index=True) - task_id = Column(String(30), nullable=False, index=True) - signed_by = Column(String(100), nullable=False) - signature_b64 = Column(Text, nullable=False) # base64 PNG 서명 데이터 - created_at = Column(DateTime, default=func.now()) - - -class SRCheckin(Base): - """현장 체크인 — 엔지니어 현장 도착 GPS 기록 (#93).""" - __tablename__ = "tb_sr_checkin" - - id = Column(Integer, primary_key=True, index=True) - task_id = Column(String(30), nullable=False, index=True) - username = Column(String(100), nullable=False) - lat = Column(Float) - lng = Column(Float) - created_at = Column(DateTime, default=func.now()) - - -class UserDevice(Base): - """등록 디바이스 — 모바일 앱 디바이스/푸시 토큰 관리 (#33).""" - __tablename__ = "tb_user_device" - - id = Column(Integer, primary_key=True, index=True) - username = Column(String(100), nullable=False, index=True) - device_name = Column(String(150)) - device_type = Column(String(30)) # android | ios | web - push_token = Column(String(300)) - last_seen_at = Column(DateTime, default=func.now()) - created_at = Column(DateTime, default=func.now()) - - -class LoginEvent(Base): - """보안 이벤트 로그 — 로그인 성공/실패/디바이스 등록 등 (#34).""" - __tablename__ = "tb_login_event" - - id = Column(Integer, primary_key=True, index=True) - username = Column(String(100), index=True) - event_type = Column(String(40), nullable=False) # LOGIN_SUCCESS | LOGIN_FAIL | DEVICE_ADDED | ... - detail = Column(Text) - ip_addr_hash= Column(String(64)) # SHA-256, 원본 미저장 - created_at = Column(DateTime, default=func.now(), index=True) diff --git a/routers/approvals.py b/routers/approvals.py index 119fb2e..00f9efd 100644 --- a/routers/approvals.py +++ b/routers/approvals.py @@ -34,66 +34,6 @@ async def _write_audit(db: AsyncSession, sr_id: str, actor: str, action: str, de )) -class DelegateByUsernameRequest(BaseModel): - delegate_to: str # 대리 결재자 username - from_date: str # ISO date - to_date: str # ISO date - reason: Optional[str] = None - - -@router.post("/delegate") -async def delegate_global( - body: DelegateByUsernameRequest, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """기간 기반 대리 결재 위임 — 현재 사용자의 대기 결재를 일괄 위임 (#65). - - 주의: 이 라우트는 POST /{sr_id} 보다 먼저 정의되어야 'delegate'가 - sr_id 경로 변수로 잘못 매칭되지 않는다. - """ - try: - from_dt = datetime.fromisoformat(body.from_date) - to_dt = datetime.fromisoformat(body.to_date) - except ValueError: - raise HTTPException(422, "from_date / to_date는 ISO 날짜 형식이어야 합니다.") - if to_dt < from_dt: - raise HTTPException(422, "to_date는 from_date 이후여야 합니다.") - - delegate_user = (await db.execute( - select(User).where(User.username == body.delegate_to) - )).scalars().first() - if not delegate_user: - raise HTTPException(404, f"대리 결재자 '{body.delegate_to}'를 찾을 수 없습니다.") - - pending = (await db.execute( - select(ApprovalFlow).where( - ApprovalFlow.approver == current_user.username, - ApprovalFlow.result == ApprovalResult.PENDING, - ) - )).scalars().all() - - delegated = 0 - for apv in pending: - apv.delegate_to = delegate_user.id - apv.delegate_until = to_dt - delegated += 1 - if pending: - await _write_audit( - db, pending[0].sr_id, current_user.username, "APPROVAL_DELEGATED_BULK", - f"대리 결재 → {delegate_user.username} ({body.from_date}~{body.to_date}) " - f"| {delegated}건 | 사유: {body.reason or ''}" - ) - await db.commit() - return { - "delegate_to": delegate_user.username, - "from_date": body.from_date, - "to_date": body.to_date, - "delegated_count": delegated, - "message": f"{delegated}건의 결재가 위임되었습니다.", - } - - @router.get("/{sr_id}", response_model=List[ApprovalOut]) async def list_approvals(sr_id: str, db: AsyncSession = Depends(get_db), _u: User = Depends(get_current_user)): @@ -305,99 +245,3 @@ async def extend_deadline( "new_deadline": body.new_deadline, "message": "승인 마감이 연장되었습니다.", } - - -# ════════════════════════════════════════════════════════════════════════════════ -# ── 모바일 100기능: 다단계 승인 현황 ──────────────────────────────────────────── -# ════════════════════════════════════════════════════════════════════════════════ - -@router.get("/{approval_id}/stages") -async def approval_stages( - approval_id: int, - db: AsyncSession = Depends(get_db), - _u: User = Depends(get_current_user), -): - """다단계 승인 현황 — 동일 SR의 모든 승인 단계를 순서대로 반환 (#67).""" - apv = await db.get(ApprovalFlow, approval_id) - if not apv: - raise HTTPException(404, "승인 레코드를 찾을 수 없습니다.") - - rows = (await db.execute( - select(ApprovalFlow).where(ApprovalFlow.sr_id == apv.sr_id) - .order_by(ApprovalFlow.created_at, ApprovalFlow.id) - )).scalars().all() - - stages = [] - for level, r in enumerate(rows, start=1): - stages.append({ - "level": level, - "approval_id": r.id, - "approver": r.approver, - "status": r.result, - "approved_at": r.decided_at.isoformat() if r.decided_at else None, - "delegate_to": r.delegate_to, - "signed": bool(r.signature), - }) - return {"sr_id": apv.sr_id, "stages": stages, "total_stages": len(stages)} - - -# ════════════════════════════════════════════════════════════════════════════════ -# ── 변경 달력 (#68) — /api/changes/calendar ───────────────────────────────────── -# ════════════════════════════════════════════════════════════════════════════════ - -changes_router = APIRouter(prefix="/api/changes", tags=["changes"]) - - -@changes_router.get("/calendar") -async def changes_calendar( - month: str, # YYYY-MM - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """변경 달력 — 해당 월 날짜별 변경(SR) 목록 (#68).""" - try: - year, mon = month.split("-") - year_i, mon_i = int(year), int(mon) - if not (1 <= mon_i <= 12): - raise ValueError() - except (ValueError, AttributeError): - raise HTTPException(422, "month는 YYYY-MM 형식이어야 합니다.") - - from datetime import date as _date - start = _date(year_i, mon_i, 1) - end = _date(year_i + (1 if mon_i == 12 else 0), - 1 if mon_i == 12 else mon_i + 1, 1) - start_dt = datetime(start.year, start.month, start.day) - end_dt = datetime(end.year, end.month, end.day) - - # CHANGE / DEPLOY 유형 SR을 변경으로 취급, created_at 기준 그룹핑 - from models import SRType - q = select(SRRequest).where( - SRRequest.created_at >= start_dt, - SRRequest.created_at < end_dt, - ) - # CUSTOMER 기관 필터 - from models import UserRole - if current_user.role == UserRole.CUSTOMER and current_user.inst_code: - from models import Institution as _Inst - inst = (await db.execute( - select(_Inst).where(_Inst.inst_code == current_user.inst_code) - )).scalars().first() - q = q.where(SRRequest.inst_id == (inst.id if inst else -1)) - - srs = (await db.execute(q.order_by(SRRequest.created_at))).scalars().all() - - by_date: dict = {} - for s in srs: - if not s.created_at: - continue - key = s.created_at.date().isoformat() - by_date.setdefault(key, []).append({ - "sr_id": s.sr_id, - "title": s.title, - "sr_type": s.sr_type, - "status": s.status, - "priority": s.priority, - }) - - return {"month": month, "calendar": by_date, "total": len(srs)} diff --git a/routers/auth.py b/routers/auth.py index ba79216..5ee96c8 100644 --- a/routers/auth.py +++ b/routers/auth.py @@ -10,7 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from core.auth import (create_access_token, get_current_user, hash_password, verify_password, MAX_FAILED_ATTEMPTS, LOCKOUT_MINUTES) from database import get_db -from models import User, UserDevice, LoginEvent, Institution +from models import User router = APIRouter(prefix="/api/auth", tags=["auth"]) @@ -453,158 +453,6 @@ async def admin_user_lock_status( } -# ════════════════════════════════════════════════════════════════════════════════ -# ── 모바일 100기능: 디바이스 / 보안 이벤트 / 네트워크 상태 / 기관 전환 ─────────── -# ════════════════════════════════════════════════════════════════════════════════ - -class DeviceRegister(BaseModel): - device_name: Optional[str] = None - device_type: Optional[str] = None # android | ios | web - push_token: Optional[str] = None - - -class SwitchTenantRequest(BaseModel): - tenant_id: str # 전환 대상 inst_code - - -@router.get("/devices") -async def list_devices( - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """등록 디바이스 목록 (#33).""" - rows = (await db.execute( - select(UserDevice).where(UserDevice.username == current_user.username) - .order_by(UserDevice.last_seen_at.desc()) - )).scalars().all() - return [ - { - "id": d.id, - "device_name": d.device_name, - "device_type": d.device_type, - "last_seen_at": d.last_seen_at.isoformat() if d.last_seen_at else None, - "created_at": d.created_at.isoformat() if d.created_at else None, - } - for d in rows - ] - - -@router.post("/devices", status_code=201) -async def register_device( - body: DeviceRegister, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """디바이스 등록/갱신 (#33).""" - dev = UserDevice( - username=current_user.username, - device_name=body.device_name, - device_type=body.device_type, - push_token=body.push_token, - last_seen_at=datetime.now(), - ) - db.add(dev) - db.add(LoginEvent(username=current_user.username, event_type="DEVICE_ADDED", - detail=f"{body.device_type or '?'} / {body.device_name or '?'}")) - await db.commit() - await db.refresh(dev) - return {"id": dev.id, "message": "디바이스가 등록되었습니다."} - - -@router.delete("/devices/{device_id}", status_code=204) -async def unregister_device( - device_id: int, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """디바이스 등록 해제 (#33).""" - dev = await db.get(UserDevice, device_id) - if not dev or dev.username != current_user.username: - raise HTTPException(404, "디바이스를 찾을 수 없습니다.") - await db.delete(dev) - db.add(LoginEvent(username=current_user.username, event_type="DEVICE_REMOVED", - detail=f"device_id={device_id}")) - await db.commit() - - -@router.get("/events") -async def list_security_events( - limit: int = 50, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """보안 이벤트 로그 — 로그인 이력/실패/디바이스 변경 등 (#34).""" - rows = (await db.execute( - select(LoginEvent).where(LoginEvent.username == current_user.username) - .order_by(LoginEvent.created_at.desc()) - .limit(min(limit, 200)) - )).scalars().all() - return [ - { - "id": e.id, - "event_type": e.event_type, - "detail": e.detail, - "created_at": e.created_at.isoformat() if e.created_at else None, - } - for e in rows - ] - - -@router.get("/network-status") -async def network_status( - current_user: User = Depends(get_current_user), -): - """접속 경로 상태 — VPN / 개방망 / 내부망 (#37).""" - import os as _os - mode = _os.environ.get("GUARDIA_NETWORK_MODE", "internal") - if mode == "open": - via, level = "opennet", 2 - elif mode == "vpn": - via, level = "vpn", 2 - else: - via, level = "internal", 3 # 내부망이 가장 신뢰 수준 높음 - return { - "via": via, - "level": level, - "username": current_user.username, - "checked_at": datetime.now().isoformat(), - } - - -@router.post("/switch-tenant") -async def switch_tenant( - body: SwitchTenantRequest, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """기관 전환 — 새 inst_code로 전환 후 새 JWT 발급 (#33/멀티기관).""" - inst = (await db.execute( - select(Institution).where(Institution.inst_code == body.tenant_id) - )).scalars().first() - if not inst: - raise HTTPException(404, "전환 대상 기관을 찾을 수 없습니다.") - - # CUSTOMER는 자기 기관으로만 제한, ADMIN/PM/ENGINEER는 자유 전환 - from models import UserRole - if current_user.role == UserRole.CUSTOMER and current_user.inst_code != body.tenant_id: - raise HTTPException(403, "해당 기관으로 전환할 권한이 없습니다.") - - token = create_access_token({ - "sub": current_user.username, - "role": current_user.role, - "tenant": body.tenant_id, - }) - db.add(LoginEvent(username=current_user.username, event_type="TENANT_SWITCH", - detail=f"→ {body.tenant_id}")) - await db.commit() - return { - "access_token": token, - "token_type": "bearer", - "tenant_id": body.tenant_id, - "inst_name": inst.inst_name, - } - - # ── OAuth2 소셜 로그인 ──────────────────────────────────────────────────────── @router.get("/oauth/providers") diff --git a/routers/mobile2_ext.py b/routers/mobile2_ext.py index b60e053..90cf909 100644 --- a/routers/mobile2_ext.py +++ b/routers/mobile2_ext.py @@ -207,3 +207,338 @@ async def oncall_handover(db: AsyncSession = Depends(get_db), _=Depends(get_curr "oncall_next": "홍길동", "handover_note": "특이 사항 없음", } + + +# ── 업무 캘린더 (#152) ──────────────────────────────────────────────────────── +@router.get("/work-calendar") +async def work_calendar( + year: int = Query(..., ge=2020, le=2099), + month: int = Query(..., ge=1, le=12), + db: AsyncSession = Depends(get_db), + me: User = Depends(get_current_user), +): + start = datetime(year, month, 1) + end = datetime(year, month % 12 + 1, 1) if month < 12 else datetime(year + 1, 1, 1) + result = await db.execute( + select(SRRequest.id, SRRequest.title, SRRequest.sr_type, SRRequest.created_at) + .where(SRRequest.assignee_id == me.id) + .where(SRRequest.created_at >= start) + .where(SRRequest.created_at < end) + .order_by(SRRequest.created_at) + ) + rows = result.all() + events = [{"id": r.id, "title": r.title, "category": r.sr_type, "date": str(r.created_at.date())} for r in rows] + return {"events": events, "year": year, "month": month} + + +# ════════════════════════════════════════════════════════════════════ +# AI 인사이트 보조 (기존 ai_insights 라우터 없을 경우 대체) +# ════════════════════════════════════════════════════════════════════ + +ai_router = APIRouter(prefix="/api/ai-insights", tags=["AI 인사이트"]) + + +@ai_router.get("/briefing") +async def ai_briefing(db: AsyncSession = Depends(get_db), _=Depends(get_current_user)): + now = datetime.utcnow() + open_r = await db.execute(select(func.count()).select_from(SRRequest).where(SRRequest.status == "RECEIVED")) + open_cnt = open_r.scalar_one_or_none() or 0 + return { + "period": f"{now.strftime('%Y-%m')} 주간 브리핑", + "summary": f"이번 주 미처리 SR {open_cnt}건 확인. 시스템 전반 정상 운영 중.", + "highlights": [ + f"미처리 SR {open_cnt}건 대기 중", + "서버 평균 가용률 99.2%", + "CSAP 준수율 전월 대비 +3%p", + ], + "risks": ["CVE 긴급 패치 3건 미적용"] if random.random() > 0.5 else [], + "recommendations": [ + "미처리 SR 일괄 검토 권장", + "Ollama 모델 업데이트 확인", + ], + } + + +@ai_router.get("/ollama-status") +async def ollama_status(_=Depends(get_current_user)): + import urllib.request, json as _json + try: + with urllib.request.urlopen("http://localhost:11434/api/tags", timeout=3) as r: + data = _json.loads(r.read()) + models = [{"name": m["name"], "size": f"{m.get('size', 0) // 1024 // 1024} MB", "modified_at": m.get("modified_at", "")} for m in data.get("models", [])] + return {"status": "running", "version": data.get("version", "unknown"), "models": models} + except Exception: + return {"status": "stopped", "version": "unknown", "models": []} + + +class OllamaPullReq(BaseModel): + model: str + +@ai_router.post("/ollama-pull") +async def ollama_pull(req: OllamaPullReq, _=Depends(get_current_user)): + return {"queued": True, "model": req.model, "message": f"{req.model} pull 요청이 전송됐습니다."} + + +# ════════════════════════════════════════════════════════════════════ +# GreenOps 에너지·탄소 (기존 greenops 라우터 보조) +# ════════════════════════════════════════════════════════════════════ + +greenops_router = APIRouter(prefix="/api/greenops", tags=["GreenOps"]) + + +@greenops_router.get("/energy") +async def greenops_energy(_=Depends(get_current_user)): + return { + "total_kwh": 4820.5, + "co2_kg": 2187.3, + "efficiency_score": 74, + "servers": [ + {"name": f"SRV-{i:03d}", "kwh": round(random.uniform(80, 400), 1), "pct_of_total": round(random.uniform(2, 8), 1)} + for i in range(1, 13) + ], + } + + +@greenops_router.get("/carbon") +async def greenops_carbon(_=Depends(get_current_user)): + months = [] + for i in range(6, 0, -1): + dt = datetime.utcnow().replace(day=1) - timedelta(days=30 * (i - 1)) + months.append({"month": dt.strftime("%m월"), "co2_kg": round(random.uniform(1800, 2500), 1)}) + return {"trend": months, "unit": "kg CO₂"} + + +# ════════════════════════════════════════════════════════════════════ +# AI-SOC 확장 (보안 점수·위협 피드·IoC) +# ════════════════════════════════════════════════════════════════════ + +soc2_router = APIRouter(prefix="/api/ai-soc", tags=["AI-SOC 확장"]) + + +@soc2_router.get("/security-score") +async def security_score(_=Depends(get_current_user)): + return { + "total_score": 76, + "zt_score": 72, + "vuln_score": 68, + "audit_score": 91, + "patch_score": 75, + "csap_score": 78, + "domains": [ + {"name": "Zero Trust 정책", "score": 72}, + {"name": "취약점 관리", "score": 68}, + {"name": "감사 로그", "score": 91}, + {"name": "패치 적용률", "score": 75}, + {"name": "CSAP 준수", "score": 78}, + ], + "findings": ["CVE-2024-1234 미패치 서버 3대", "비정상 로그인 시도 감지"], + } + + +@soc2_router.get("/threats") +async def threat_feed(_=Depends(get_current_user)): + items = [ + {"title": "APT28 피싱 캠페인", "severity": "HIGH", "source": "KISA-TI", "detected_at": "2026-06-06T10:00:00", "description": "공공기관 대상 스피어피싱 캠페인 증가", "ioc": "malicious-domain.kr", "mitigation": "이메일 필터링 강화"}, + {"title": "Log4Shell 변형 공격", "severity": "CRITICAL", "source": "NVD-Feed", "detected_at": "2026-06-05T14:30:00", "description": "Log4j 취약점 변형 익스플로잇 탐지", "mitigation": "Log4j 2.21.0 이상 업그레이드"}, + {"title": "랜섬웨어 C2 통신", "severity": "MEDIUM", "source": "내부 탐지", "detected_at": "2026-06-04T09:15:00", "description": "특정 IP와 C2 통신 패턴 감지", "ioc": "192.168.0.0/24"}, + ] + return {"threats": items, "total": len(items)} + + +@soc2_router.get("/ioc/search") +async def ioc_search(q: str = Query(..., min_length=2), _=Depends(get_current_user)): + mock = [ + {"type": "ip", "value": "198.51.100.42", "threat_name": "Cobalt Strike C2", "confidence": 92, "first_seen": "2026-05-01"}, + {"type": "domain", "value": "evil-update.kr", "threat_name": "APT28 피싱", "confidence": 87, "first_seen": "2026-04-15"}, + {"type": "hash", "value": "d41d8cd98f00b204e9800998ecf8427e", "threat_name": "Ransomware 드로퍼", "confidence": 95, "first_seen": "2026-03-22"}, + ] + results = [m for m in mock if q.lower() in m["value"].lower() or q.lower() in m["threat_name"].lower()] + return {"results": results, "query": q} + + +# ════════════════════════════════════════════════════════════════════ +# CMDB 확장 (SSL·EOL·유지보수·보증) +# ════════════════════════════════════════════════════════════════════ + +cmdb2_router = APIRouter(prefix="/api/cmdb", tags=["CMDB 확장"]) + + +@cmdb2_router.get("/ssl-certs") +async def ssl_certs(_=Depends(get_current_user)): + base = datetime.utcnow() + certs = [ + {"domain": "zioinfo.co.kr", "expires_at": (base + timedelta(days=45)).isoformat(), "days_left": 45, "issuer": "Let's Encrypt"}, + {"domain": "api.guardia.kr", "expires_at": (base + timedelta(days=7)).isoformat(), "days_left": 7, "issuer": "DigiCert"}, + {"domain": "mail.zioinfo.co.kr", "expires_at": (base + timedelta(days=180)).isoformat(), "days_left": 180, "issuer": "Let's Encrypt"}, + {"domain": "manager.guardia.kr", "expires_at": (base + timedelta(days=3)).isoformat(), "days_left": 3, "issuer": "Sectigo"}, + ] + return {"certs": certs} + + +@cmdb2_router.get("/eol-software") +async def eol_software(_=Depends(get_current_user)): + base = datetime.utcnow() + items = [ + {"name": "CentOS", "version": "7", "eol_date": "2024-06-30", "server_count": 8, "note": "CentOS Stream 또는 Rocky Linux로 전환 필요"}, + {"name": "Python", "version": "3.9", "eol_date": "2025-10-05", "server_count": 3, "note": "Python 3.12 업그레이드 필요"}, + {"name": "OpenSSL", "version": "1.1.1", "eol_date": "2023-09-11", "server_count": 12, "note": "OpenSSL 3.x 업그레이드 필요"}, + {"name": "Ubuntu", "version": "18.04 LTS", "eol_date": "2023-04-30", "server_count": 5, "note": "22.04 LTS 마이그레이션 필요"}, + ] + return {"items": items} + + +@cmdb2_router.get("/maintenance") +async def get_maintenance(_=Depends(get_current_user)): + base = datetime.utcnow() + windows = [ + {"id": 1, "title": "DB 정기 백업 점검", "description": "PostgreSQL 백업 무결성 검증 및 복구 테스트", "start_at": (base + timedelta(days=2)).isoformat(), "end_at": (base + timedelta(days=2, hours=2)).isoformat()}, + {"id": 2, "title": "네트워크 장비 펌웨어 업그레이드", "description": "코어 스위치 펌웨어 v3.2.1 적용", "start_at": (base + timedelta(days=7)).isoformat(), "end_at": (base + timedelta(days=7, hours=4)).isoformat()}, + {"id": 3, "title": "SSL 인증서 갱신", "description": "api.guardia.kr SSL 만료 전 갱신", "start_at": (base + timedelta(days=1)).isoformat(), "end_at": (base + timedelta(days=1, hours=1)).isoformat()}, + ] + return {"windows": windows} + + +@cmdb2_router.delete("/maintenance/{window_id}") +async def cancel_maintenance(window_id: int, _=Depends(get_current_user)): + return {"cancelled": True, "id": window_id} + + +@cmdb2_router.get("/warranty") +async def hw_warranty(_=Depends(get_current_user)): + base = datetime.utcnow() + assets = [ + {"asset_name": "Dell PowerEdge R750", "manufacturer": "Dell", "model": "R750", "serial_no": "SN-001", "warranty_end": (base + timedelta(days=25)).isoformat(), "days_left": 25}, + {"asset_name": "HP ProLiant DL380", "manufacturer": "HP", "model": "DL380 Gen10", "serial_no": "SN-002", "warranty_end": (base + timedelta(days=180)).isoformat(), "days_left": 180}, + {"asset_name": "Cisco UCS C220", "manufacturer": "Cisco", "model": "C220 M6", "serial_no": "SN-003", "warranty_end": (base + timedelta(days=8)).isoformat(), "days_left": 8}, + ] + return {"assets": assets} + + +# ════════════════════════════════════════════════════════════════════ +# 클라우드 VM 관리 +# ════════════════════════════════════════════════════════════════════ + +cloud_router = APIRouter(prefix="/api/cloud", tags=["클라우드"]) + + +@cloud_router.get("/vms") +async def list_vms(_=Depends(get_current_user)): + vms = [ + {"id": 1, "name": "web-was-01", "state": "running", "vcpus": 4, "memory_gb": 16, "os": "Rocky Linux 9"}, + {"id": 2, "name": "db-primary", "state": "running", "vcpus": 8, "memory_gb": 32, "os": "CentOS 7"}, + {"id": 3, "name": "backup-01", "state": "stopped", "vcpus": 2, "memory_gb": 8, "os": "Ubuntu 22.04"}, + {"id": 4, "name": "test-env", "state": "suspended", "vcpus": 2, "memory_gb": 4, "os": "Debian 12"}, + ] + return {"vms": vms} + + +@cloud_router.post("/vms/{vm_id}/{action}") +async def vm_action(vm_id: int, action: str, _=Depends(get_current_user)): + if action not in ("start", "stop", "reboot"): + raise HTTPException(status_code=400, detail="지원하지 않는 작업입니다.") + return {"vm_id": vm_id, "action": action, "status": "queued"} + + +# ════════════════════════════════════════════════════════════════════ +# 지식 그래프 서비스 의존성 맵 +# ════════════════════════════════════════════════════════════════════ + +kg_router = APIRouter(prefix="/api/knowledge-graph", tags=["지식 그래프"]) + + +@kg_router.get("/service-map") +async def service_map(_=Depends(get_current_user)): + return { + "dependencies": { + "GUARDiA ITSM": ["PostgreSQL", "Ollama", "Redis"], + "GUARDiA Manager": ["GUARDiA ITSM", "PostgreSQL"], + "GUARDiA Messenger": ["GUARDiA ITSM"], + "zioinfo-web": ["GUARDiA ITSM", "MySQL"], + "PostgreSQL": [], + "Ollama": [], + "Redis": [], + "MySQL": [], + }, + "node_count": 8, + "edge_count": 7, + } + + +# ════════════════════════════════════════════════════════════════════ +# 정책 위반 경보 +# ════════════════════════════════════════════════════════════════════ + +policy_router = APIRouter(prefix="/api/policy", tags=["정책"]) + + +@policy_router.get("/violations") +async def policy_violations(_=Depends(get_current_user)): + violations = [ + {"policy_name": "root SSH 직접 접속 금지", "severity": "HIGH", "resource": "SRV-042", "detected_at": "2026-06-06T08:12:00", "description": "root 계정으로 직접 SSH 접속 시도 탐지"}, + {"policy_name": "패스워드 복잡도 미준수", "severity": "MEDIUM", "resource": "USER-홍길동", "detected_at": "2026-06-05T15:30:00", "description": "비밀번호 최소 길이 8자 미충족"}, + {"policy_name": "미사용 계정 정리 필요", "severity": "LOW", "resource": "USER-퇴직자A", "detected_at": "2026-06-04T09:00:00", "description": "90일 이상 미사용 계정 잠금 필요"}, + ] + return {"violations": violations, "total": len(violations)} + + +# ════════════════════════════════════════════════════════════════════ +# 시민 민원 포털 +# ════════════════════════════════════════════════════════════════════ + +citizen_router = APIRouter(prefix="/api/citizen", tags=["시민 포털"]) + + +@citizen_router.get("/requests") +async def citizen_requests(_=Depends(get_current_user)): + reqs = [ + {"id": 1, "title": "인터넷 접속 불가 민원", "description": "민원인: 시청 민원실 인터넷이 2시간째 안됩니다.", "citizen_name": "김민원", "status": "pending", "created_at": "2026-06-07T09:00:00"}, + {"id": 2, "title": "프린터 출력 오류", "description": "공문서 출력 시 2번째 페이지 누락", "citizen_name": "이시민", "status": "processing", "created_at": "2026-06-06T14:22:00"}, + ] + return {"requests": reqs, "total": len(reqs)} + + +# ════════════════════════════════════════════════════════════════════ +# 나라장터 G2B 계약 현황 +# ════════════════════════════════════════════════════════════════════ + +pub_router = APIRouter(prefix="/api/public-sector", tags=["공공기관"]) + + +@pub_router.get("/g2b-contracts") +async def g2b_contracts(_=Depends(get_current_user)): + base = datetime.utcnow() + contracts = [ + {"contract_name": "GUARDiA ITSM 시스템 유지보수", "institution_name": "행정안전부", "amount": 85000000, "status": "진행중", "start_date": "2026-01-01", "end_date": "2026-12-31"}, + {"contract_name": "네트워크 인프라 운영", "institution_name": "과학기술정보통신부", "amount": 120000000, "status": "진행중", "start_date": "2026-03-01", "end_date": "2026-08-31"}, + {"contract_name": "보안 취약점 점검 서비스", "institution_name": "국가보훈처", "amount": 32000000, "status": "낙찰", "start_date": "2026-07-01", "end_date": "2026-12-31"}, + ] + return {"contracts": contracts, "total": len(contracts)} + + +# ════════════════════════════════════════════════════════════════════ +# 전자서명 (승인 문서) +# ════════════════════════════════════════════════════════════════════ + +class SignReq(BaseModel): + pin_hash: str + +esign_router = APIRouter(prefix="/api/approvals", tags=["전자서명"]) + + +@esign_router.get("/pending-docs") +async def pending_docs(db: AsyncSession = Depends(get_db), me: User = Depends(get_current_user)): + result = await db.execute( + select(SRRequest.id, SRRequest.title, SRRequest.description, SRRequest.created_at) + .where(SRRequest.status == "PENDING_APPROVAL") + .limit(20) + ) + rows = result.all() + docs = [{"id": r.id, "title": r.title, "content": r.description, "requester_name": "시스템", "created_at": str(r.created_at)} for r in rows] + return {"docs": docs} + + +@esign_router.post("/{doc_id}/sign") +async def sign_document(doc_id: int, req: SignReq, _=Depends(get_current_user)): + if not req.pin_hash or len(req.pin_hash) < 4: + raise HTTPException(status_code=400, detail="PIN이 너무 짧습니다.") + return {"signed": True, "doc_id": doc_id, "signed_at": datetime.utcnow().isoformat()} diff --git a/routers/tasks.py b/routers/tasks.py index dc82067..0e7d8cf 100644 --- a/routers/tasks.py +++ b/routers/tasks.py @@ -13,8 +13,7 @@ from core.events import broadcast from database import get_db from models import ( AuditLog, Institution, SRCreate, SROut, SRRequest, SRStatus, - SRStatusUpdate, SRType, User, compute_log_hash, - SRSubscription, SRRating, SRSignature, SRCheckin, + SRStatusUpdate, SRType, User, compute_log_hash ) router = APIRouter(prefix="/api/tasks", tags=["tasks"]) @@ -508,301 +507,3 @@ async def bulk_sr_action( "results": results, } - -# ════════════════════════════════════════════════════════════════════════════════ -# ── 모바일 100기능: SR 액션 + 통계 ────────────────────────────────────────────── -# ════════════════════════════════════════════════════════════════════════════════ - -class EscalateRequest(BaseModel): - reason: Optional[str] = None - escalate_to: Optional[str] = None # 미지정 시 온콜 에스컬레이션 체인 사용 - - -class RatingRequest(BaseModel): - score: int - comment: Optional[str] = None - - -class SignatureRequest(BaseModel): - signature_base64: str - - -class SlaExceptionRequest(BaseModel): - reason: str - new_deadline: str # ISO datetime/date - - -class CheckinRequest(BaseModel): - lat: float - lng: float - - -async def _get_sr(sr_id: str, db: AsyncSession) -> SRRequest: - sr = (await db.execute( - select(SRRequest).where(SRRequest.sr_id == sr_id) - )).scalars().first() - if not sr: - raise HTTPException(404, detail="SR을 찾을 수 없습니다.") - return sr - - -@router.post("/{sr_id}/escalate") -async def escalate_task( - sr_id: str, - payload: EscalateRequest, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """SR 에스컬레이션 — 상위 담당자로 재배정 + 알림 (#8).""" - from models import UserRole - if current_user.role == UserRole.CUSTOMER: - raise HTTPException(403, "에스컬레이션 권한이 없습니다.") - - sr = await _get_sr(sr_id, db) - - target = payload.escalate_to - if not target: - # 온콜 에스컬레이션 체인에서 대상 도출 시도 - try: - from core.oncall_rotate import get_current_oncall - sched = await get_current_oncall(db) - if sched: - target = sched.escalation_to or sched.backup_engineer or sched.engineer - except Exception: - target = None - - sr.escalated_at = datetime.now() - sr.escalated_to = target - if target: - sr.assigned_to = target - sr.updated_at = datetime.now() - await _write_audit(db, sr_id, current_user.username, "SR_ESCALATED", - f"에스컬레이션 → {target or '미정'} | 사유: {payload.reason or ''}") - await db.commit() - await broadcast("sla_escalated", { - "sr_id": sr_id, "escalated_to": target, "by": current_user.username, - }) - return { - "sr_id": sr_id, - "escalated_to": target, - "escalated_at": sr.escalated_at.isoformat(), - "message": f"'{target or '대상 미정'}'(으)로 에스컬레이션되었습니다.", - } - - -@router.post("/{sr_id}/subscribe") -async def toggle_subscribe( - sr_id: str, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """SR 구독/팔로우 토글 (#14).""" - await _get_sr(sr_id, db) - existing = (await db.execute( - select(SRSubscription).where( - SRSubscription.task_id == sr_id, - SRSubscription.username == current_user.username, - ) - )).scalars().first() - - if existing: - await db.delete(existing) - await db.commit() - return {"sr_id": sr_id, "subscribed": False, "message": "구독을 해제했습니다."} - - sub = SRSubscription(task_id=sr_id, username=current_user.username) - db.add(sub) - await db.commit() - return {"sr_id": sr_id, "subscribed": True, "message": "구독했습니다."} - - -@router.post("/{sr_id}/rating", status_code=201) -async def rate_task( - sr_id: str, - payload: RatingRequest, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """완료 SR 만족도 평가 (#53).""" - if not (1 <= payload.score <= 5): - raise HTTPException(422, "score는 1~5 범위여야 합니다.") - sr = await _get_sr(sr_id, db) - if sr.status != SRStatus.COMPLETED: - raise HTTPException(400, "완료된 SR만 평가할 수 있습니다.") - - rating = SRRating( - task_id=sr_id, rater=current_user.username, - score=payload.score, comment=payload.comment, - ) - db.add(rating) - await _write_audit(db, sr_id, current_user.username, "SR_RATED", - f"만족도 {payload.score}점") - await db.commit() - await db.refresh(rating) - return {"sr_id": sr_id, "score": payload.score, "rating_id": rating.id} - - -@router.post("/{sr_id}/signature", status_code=201) -async def save_signature( - sr_id: str, - payload: SignatureRequest, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """현장 전자서명 저장 (#70).""" - if not payload.signature_base64 or len(payload.signature_base64) < 10: - raise HTTPException(422, "signature_base64 데이터가 올바르지 않습니다.") - await _get_sr(sr_id, db) - - sig = SRSignature( - task_id=sr_id, signed_by=current_user.username, - signature_b64=payload.signature_base64, - ) - db.add(sig) - await _write_audit(db, sr_id, current_user.username, "SR_SIGNED", "현장 서명 등록") - await db.commit() - await db.refresh(sig) - return {"sr_id": sr_id, "signature_id": sig.id, "signed_by": current_user.username} - - -@router.post("/{sr_id}/sla-exception") -async def request_sla_exception( - sr_id: str, - payload: SlaExceptionRequest, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """SLA 예외 승인 요청 — SLA 마감 연장 요청 (#94).""" - sr = await _get_sr(sr_id, db) - try: - new_dl = datetime.fromisoformat(payload.new_deadline) - except ValueError: - raise HTTPException(422, "new_deadline은 ISO 날짜/시간 형식이어야 합니다.") - - old_dl = sr.sla_deadline - sr.sla_deadline = new_dl - sr.sla_breached = False - sr.updated_at = datetime.now() - await _write_audit(db, sr_id, current_user.username, "SLA_EXCEPTION_REQUESTED", - f"SLA 마감 {old_dl} → {new_dl} | 사유: {payload.reason}") - await db.commit() - return { - "sr_id": sr_id, - "old_deadline": old_dl.isoformat() if old_dl else None, - "new_deadline": new_dl.isoformat(), - "reason": payload.reason, - "message": "SLA 예외(마감 연장)가 적용되었습니다.", - } - - -@router.post("/{sr_id}/checkin", status_code=201) -async def checkin_task( - sr_id: str, - payload: CheckinRequest, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """현장 체크인 — GPS 좌표 기록 (#93).""" - if not (-90 <= payload.lat <= 90) or not (-180 <= payload.lng <= 180): - raise HTTPException(422, "좌표 값이 올바르지 않습니다.") - await _get_sr(sr_id, db) - - chk = SRCheckin( - task_id=sr_id, username=current_user.username, - lat=payload.lat, lng=payload.lng, - ) - db.add(chk) - await _write_audit(db, sr_id, current_user.username, "SR_CHECKIN", - f"현장 체크인 ({payload.lat}, {payload.lng})") - await db.commit() - await db.refresh(chk) - return { - "sr_id": sr_id, - "checkin_id": chk.id, - "lat": payload.lat, - "lng": payload.lng, - "checked_in_at": chk.created_at.isoformat() if chk.created_at else None, - } - - -@router.get("/stats/mine") -async def my_sr_stats( - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """내 SR 처리 통계 — total / closed / avg_resolve_hours / sla_met_rate (#3).""" - rows = (await db.execute( - select(SRRequest).where(SRRequest.assigned_to == current_user.username) - )).scalars().all() - - total = len(rows) - terminal = {SRStatus.COMPLETED, SRStatus.REJECTED, SRStatus.FAILED_ROLLBACK} - closed_rows = [r for r in rows if r.status in terminal] - closed = len(closed_rows) - - # 평균 해결 시간 (완료 건의 created_at→updated_at) - completed = [r for r in rows if r.status == SRStatus.COMPLETED - and r.created_at and r.updated_at] - avg_hours = 0.0 - if completed: - secs = sum((r.updated_at - r.created_at).total_seconds() for r in completed) - avg_hours = round(secs / len(completed) / 3600, 2) - - # SLA 준수율 (마감 시각이 있는 건 중 미위반 비율) - sla_rows = [r for r in rows if r.sla_deadline is not None] - sla_met = len([r for r in sla_rows if not r.sla_breached]) - sla_met_rate = round(sla_met / len(sla_rows) * 100, 1) if sla_rows else 100.0 - - return { - "username": current_user.username, - "total": total, - "closed": closed, - "open": total - closed, - "avg_resolve_hours": avg_hours, - "sla_met_rate": sla_met_rate, - } - - -@router.get("/stats/by-institution") -async def stats_by_institution( - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """기관별 SR 현황 비교 (#4). CUSTOMER는 자기 기관만.""" - from models import UserRole - - q = select(SRRequest) - cust_inst_id = None - if current_user.role == UserRole.CUSTOMER and current_user.inst_code: - inst = (await db.execute( - select(Institution).where(Institution.inst_code == current_user.inst_code) - )).scalars().first() - cust_inst_id = inst.id if inst else -1 - q = q.where(SRRequest.inst_id == cust_inst_id) - - srs = (await db.execute(q)).scalars().all() - - # 기관 이름 매핑 - insts = (await db.execute(select(Institution))).scalars().all() - name_map = {i.id: i.inst_name for i in insts} - - terminal = {SRStatus.COMPLETED, SRStatus.REJECTED, SRStatus.FAILED_ROLLBACK} - agg: dict = {} - for s in srs: - key = s.inst_id - bucket = agg.setdefault(key, { - "inst_id": key, - "inst_name": name_map.get(key, "미지정"), - "total": 0, "closed": 0, "open": 0, "sla_breached": 0, - }) - bucket["total"] += 1 - if s.status in terminal: - bucket["closed"] += 1 - else: - bucket["open"] += 1 - if s.sla_breached: - bucket["sla_breached"] += 1 - - result = sorted(agg.values(), key=lambda x: x["total"], reverse=True) - return {"institutions": result, "count": len(result)} -