zioinfo-mail/itsm/routers/pam.py
DESKTOP-TKLFCPR\ython e228faabf5 feat(itsm): G-1~G-12 확장 기능 + 하네스/봇/설치스크립트 구현
G-1: 메신저 Webhook Relay + _send_to_room 실제 httpx 호출 구현
G-2: POST /api/tasks/bulk SR 대량작업 엔드포인트 (최대 100건)
G-3: 라이선스 만료 알림 스케줄러 (매일 09:00 KST)
G-4: 체험판 upgrade_banner 필드 + license.py 배너 로직
G-5: core/auto_rca.py + incidents/problem auto-rca 엔드포인트
G-6: core/deploy_impact.py + vibe impact-analysis 엔드포인트
G-7: core/ticket_classifier.py + SR 생성 시 AI 분류 + ai-suggestion API
G-8: VulnPatchRecord 모델 + vuln_scan 패치추적 4개 엔드포인트
G-9: core/jira_sync.py + gateway Jira/Confluence 연동 엔드포인트
G-10: core/push_notify.py + routers/push.py + PushSubscription 모델
G-11: approvals 다중승인 (위임/서명/기한초과/마감연장)
G-12: alembic.ini + migrations/ + cicd/migrate_to_postgres.sh

하네스: guardia-orchestrator 확장기능 Phase 반영
봇명령어: /sr /status /license /bulk 슬래시 명령어 추가
설치스크립트: setup/ (Ubuntu, CentOS, RHEL, Windows) --test 옵션 포함

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 18:18:52 +09:00

535 lines
20 KiB
Python

"""
D-3: 특권 접근 관리 (PAM - Privileged Access Management)
기능:
1. 임시 특권 세션 발급 (시간 제한 + 목적 기록)
2. 세션 체크아웃/체크인 (자격증명 일회성 대여)
3. 명령어 실행 로깅 (누가 언제 무엇을 실행했는지)
4. 세션 강제 종료 (ADMIN)
5. 접근 요청 승인 워크플로우 (PM/ADMIN 승인)
보안:
- 세션 자격증명은 AES-256-GCM 암호화
- root 직접 접속 차단 (ssh_user != root)
- 위험 명령어 실행 요청 자동 차단
- 감사 로그 필수 기록
엔드포인트:
POST /api/pam/sessions — 특권 세션 요청
GET /api/pam/sessions — 세션 목록
GET /api/pam/sessions/{id} — 세션 상세
POST /api/pam/sessions/{id}/approve — 세션 승인 (PM/ADMIN)
POST /api/pam/sessions/{id}/reject — 세션 거부
POST /api/pam/sessions/{id}/checkout — 자격증명 체크아웃
POST /api/pam/sessions/{id}/checkin — 세션 종료 (체크인)
POST /api/pam/sessions/{id}/terminate — 강제 종료 (ADMIN)
POST /api/pam/sessions/{id}/execute — 특권 명령어 실행
GET /api/pam/sessions/{id}/commands — 실행 명령어 이력
GET /api/pam/stats — PAM 통계
GET /api/pam/policies — 접근 정책 목록
"""
from __future__ import annotations
import hashlib
import logging
import os
from datetime import datetime, timedelta
from typing import List, Optional
from fastapi import APIRouter, Body, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import select, desc, func, and_
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from database import get_db
from models import User, UserRole
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/pam", tags=["pam"])
# ── 위험 명령어 패턴 (GUARDiA 보안 규칙 §7) ──────────────────────────────────
_DANGER_PATTERNS = [
"rm -rf /", "rm -rf /*", "mkfs", "dd if=", "shutdown", "halt", "reboot",
"init 0", "init 6", ":(){ :|:& };:", "chmod 777 /", "chown root /",
"> /dev/sda", "fdisk /dev/", "wipefs", "shred /dev/",
"DROP DATABASE", "DROP TABLE", "DELETE FROM", "TRUNCATE",
]
# ── 세션 상태 ─────────────────────────────────────────────────────────────────
SESSION_STATUS = {
"PENDING": "승인 대기",
"APPROVED": "승인됨",
"REJECTED": "거부됨",
"ACTIVE": "사용 중",
"COMPLETED": "정상 종료",
"TERMINATED": "강제 종료",
"EXPIRED": "만료",
}
# ── 인메모리 스토어 (실운영: DB 테이블로 이전) ────────────────────────────────
_sessions: dict = {} # session_id -> dict
_commands: dict = {} # session_id -> list of dicts
_next_seq: int = 1
def _gen_session_id() -> str:
global _next_seq
today = datetime.utcnow().strftime("%Y%m%d")
sid = f"PAM-{today}-{_next_seq:04d}"
_next_seq += 1
return sid
def _check_danger(command: str) -> Optional[str]:
"""위험 명령어 패턴 검사."""
cmd_lower = command.lower()
for pat in _DANGER_PATTERNS:
if pat.lower() in cmd_lower:
return pat
return None
def _is_expired(session: dict) -> bool:
expires_at = session.get("expires_at")
if expires_at and datetime.utcnow() > expires_at:
return True
return False
# ── Pydantic 스키마 ──────────────────────────────────────────────────────────
class SessionRequestIn(BaseModel):
target_server: str # 접근 대상 서버 (호스트명/IP는 마스킹됨)
purpose: str # 접근 목적 (필수)
requested_hours: float = 2.0 # 요청 시간 (최대 8h)
access_level: str = "READ" # READ | WRITE | ADMIN
sr_id: Optional[str] = None # 연관 SR ID
class SessionApproveIn(BaseModel):
comment: Optional[str] = None
class CommandExecuteIn(BaseModel):
command: str
reason: Optional[str] = None
# ── 엔드포인트 ───────────────────────────────────────────────────────────────
@router.post("/sessions", status_code=201)
async def request_session(
body: SessionRequestIn,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""특권 세션 요청 (승인 워크플로우 시작)."""
if body.requested_hours > 8:
raise HTTPException(400, "최대 요청 시간은 8시간입니다.")
if body.requested_hours <= 0:
raise HTTPException(400, "요청 시간은 0보다 커야 합니다.")
if not body.purpose or len(body.purpose.strip()) < 10:
raise HTTPException(400, "접근 목적은 10자 이상 입력해야 합니다.")
session_id = _gen_session_id()
now = datetime.utcnow()
session = {
"session_id": session_id,
"requester": current_user.username,
"target_server": body.target_server,
"purpose": body.purpose,
"requested_hours": body.requested_hours,
"access_level": body.access_level.upper(),
"sr_id": body.sr_id,
"status": "PENDING",
"created_at": now,
"approved_by": None,
"approved_at": None,
"checked_out_at": None,
"expires_at": None,
"completed_at": None,
"reject_reason": None,
}
_sessions[session_id] = session
_commands[session_id] = []
logger.info("PAM 세션 요청: %s by %s%s (%s)",
session_id, current_user.username, body.target_server, body.purpose[:40])
# ENGINEER는 ADMIN/PM 승인 필요. ADMIN은 자동 승인 가능
if current_user.role == UserRole.ADMIN and body.access_level != "ADMIN":
session["status"] = "APPROVED"
session["approved_by"] = current_user.username
session["approved_at"] = now
logger.info("PAM 세션 자동 승인 (ADMIN): %s", session_id)
return {
"session_id": session_id,
"status": session["status"],
"message": "세션이 요청되었습니다. PM/ADMIN 승인 후 체크아웃 가능합니다."
if session["status"] == "PENDING"
else "ADMIN 권한으로 자동 승인되었습니다.",
"auto_approved": session["status"] == "APPROVED",
}
@router.get("/sessions")
async def list_sessions(
status: Optional[str] = Query(None),
requester: Optional[str] = Query(None),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
current_user: User = Depends(get_current_user),
_db: AsyncSession = Depends(get_db),
):
"""세션 목록 조회."""
sessions = list(_sessions.values())
# 일반 ENGINEER는 본인 세션만
if current_user.role == UserRole.ENGINEER:
sessions = [s for s in sessions if s["requester"] == current_user.username]
if status:
sessions = [s for s in sessions if s["status"] == status.upper()]
if requester:
sessions = [s for s in sessions if requester in s["requester"]]
# 만료 체크
for s in sessions:
if s["status"] == "ACTIVE" and _is_expired(s):
s["status"] = "EXPIRED"
logger.info("PAM 세션 만료: %s", s["session_id"])
sessions_sorted = sorted(sessions, key=lambda x: x["created_at"], reverse=True)
return {
"total": len(sessions_sorted),
"sessions": sessions_sorted[offset: offset + limit],
}
@router.get("/sessions/{session_id}")
async def get_session(
session_id: str,
current_user: User = Depends(get_current_user),
_db: AsyncSession = Depends(get_db),
):
"""세션 상세 조회."""
s = _sessions.get(session_id)
if not s:
raise HTTPException(404, f"세션 {session_id}를 찾을 수 없습니다.")
if current_user.role == UserRole.ENGINEER and s["requester"] != current_user.username:
raise HTTPException(403, "본인 세션만 조회할 수 있습니다.")
return s
@router.post("/sessions/{session_id}/approve")
async def approve_session(
session_id: str,
body: SessionApproveIn,
current_user: User = Depends(get_current_user),
_db: AsyncSession = Depends(get_db),
):
"""세션 승인 (PM/ADMIN)."""
if current_user.role not in (UserRole.ADMIN, UserRole.PM):
raise HTTPException(403, "PM/ADMIN 권한이 필요합니다.")
s = _sessions.get(session_id)
if not s:
raise HTTPException(404, f"세션 {session_id}를 찾을 수 없습니다.")
if s["status"] != "PENDING":
raise HTTPException(400, f"승인 대기 상태가 아닙니다: {s['status']}")
s["status"] = "APPROVED"
s["approved_by"] = current_user.username
s["approved_at"] = datetime.utcnow()
logger.info("PAM 세션 승인: %s by %s", session_id, current_user.username)
return {"session_id": session_id, "status": "APPROVED",
"approved_by": current_user.username,
"message": body.comment or "승인되었습니다."}
@router.post("/sessions/{session_id}/reject")
async def reject_session(
session_id: str,
reason: str = Body(..., embed=True),
current_user: User = Depends(get_current_user),
_db: AsyncSession = Depends(get_db),
):
"""세션 거부 (PM/ADMIN)."""
if current_user.role not in (UserRole.ADMIN, UserRole.PM):
raise HTTPException(403, "PM/ADMIN 권한이 필요합니다.")
s = _sessions.get(session_id)
if not s:
raise HTTPException(404, f"세션 {session_id}를 찾을 수 없습니다.")
if s["status"] != "PENDING":
raise HTTPException(400, f"승인 대기 상태가 아닙니다: {s['status']}")
s["status"] = "REJECTED"
s["reject_reason"] = reason
logger.info("PAM 세션 거부: %s by %s (reason: %s)", session_id, current_user.username, reason[:50])
return {"session_id": session_id, "status": "REJECTED", "reason": reason}
@router.post("/sessions/{session_id}/checkout")
async def checkout_session(
session_id: str,
current_user: User = Depends(get_current_user),
_db: AsyncSession = Depends(get_db),
):
"""
자격증명 체크아웃 — 세션을 ACTIVE 상태로 전환.
자격증명은 절대 응답에 포함하지 않음 (PAM 정책).
"""
s = _sessions.get(session_id)
if not s:
raise HTTPException(404, f"세션 {session_id}를 찾을 수 없습니다.")
if s["requester"] != current_user.username and current_user.role != UserRole.ADMIN:
raise HTTPException(403, "본인 세션만 체크아웃할 수 있습니다.")
if s["status"] != "APPROVED":
raise HTTPException(400, f"승인된 세션이 아닙니다: {s['status']}")
now = datetime.utcnow()
s["status"] = "ACTIVE"
s["checked_out_at"] = now
s["expires_at"] = now + timedelta(hours=s["requested_hours"])
# 토큰: 세션ID + 사용자 + 타임스탬프 해시 (실제 자격증명 아님)
token_hash = hashlib.sha256(
f"{session_id}:{current_user.username}:{now.isoformat()}".encode()
).hexdigest()[:16]
logger.info("PAM 체크아웃: %s by %s, 만료: %s",
session_id, current_user.username, s["expires_at"].isoformat())
return {
"session_id": session_id,
"status": "ACTIVE",
"access_token": token_hash, # 일회성 접근 토큰 (자격증명 아님)
"expires_at": s["expires_at"].isoformat(),
"target_server": s["target_server"],
"access_level": s["access_level"],
"note": "자격증명은 이 토큰으로 내부 SSH 프록시를 통해서만 접근 가능합니다.",
}
@router.post("/sessions/{session_id}/checkin")
async def checkin_session(
session_id: str,
comment: Optional[str] = Body(None, embed=True),
current_user: User = Depends(get_current_user),
_db: AsyncSession = Depends(get_db),
):
"""세션 체크인 (정상 종료)."""
s = _sessions.get(session_id)
if not s:
raise HTTPException(404, f"세션 {session_id}를 찾을 수 없습니다.")
if s["requester"] != current_user.username and current_user.role != UserRole.ADMIN:
raise HTTPException(403, "본인 세션만 체크인할 수 있습니다.")
if s["status"] not in ("ACTIVE", "APPROVED"):
raise HTTPException(400, f"활성 세션이 아닙니다: {s['status']}")
s["status"] = "COMPLETED"
s["completed_at"] = datetime.utcnow()
cmd_count = len(_commands.get(session_id, []))
logger.info("PAM 체크인: %s by %s (명령어 %d개 실행)", session_id, current_user.username, cmd_count)
return {
"session_id": session_id,
"status": "COMPLETED",
"commands_ran": cmd_count,
"comment": comment,
}
@router.post("/sessions/{session_id}/terminate")
async def terminate_session(
session_id: str,
reason: str = Body(..., embed=True),
current_user: User = Depends(get_current_user),
_db: AsyncSession = Depends(get_db),
):
"""세션 강제 종료 (ADMIN)."""
if current_user.role != UserRole.ADMIN:
raise HTTPException(403, "ADMIN 권한이 필요합니다.")
s = _sessions.get(session_id)
if not s:
raise HTTPException(404, f"세션 {session_id}를 찾을 수 없습니다.")
if s["status"] in ("COMPLETED", "TERMINATED", "REJECTED"):
raise HTTPException(400, f"이미 종료된 세션입니다: {s['status']}")
s["status"] = "TERMINATED"
s["completed_at"] = datetime.utcnow()
s["reject_reason"] = reason
logger.warning("PAM 강제 종료: %s by %s (reason: %s)", session_id, current_user.username, reason[:50])
return {"session_id": session_id, "status": "TERMINATED", "reason": reason,
"terminated_by": current_user.username}
@router.post("/sessions/{session_id}/execute")
async def execute_command(
session_id: str,
body: CommandExecuteIn,
current_user: User = Depends(get_current_user),
_db: AsyncSession = Depends(get_db),
):
"""
특권 세션에서 명령어 실행 요청.
- 위험 패턴 자동 차단
- 모든 명령어 감사 로그 기록
"""
s = _sessions.get(session_id)
if not s:
raise HTTPException(404, f"세션 {session_id}를 찾을 수 없습니다.")
if s["requester"] != current_user.username and current_user.role != UserRole.ADMIN:
raise HTTPException(403, "본인 세션에서만 실행할 수 있습니다.")
if s["status"] != "ACTIVE":
raise HTTPException(400, f"활성 세션이 아닙니다: {s['status']}")
if _is_expired(s):
s["status"] = "EXPIRED"
raise HTTPException(403, "세션이 만료되었습니다.")
# 위험 명령어 차단
danger = _check_danger(body.command)
if danger:
logger.warning("PAM 위험 명령어 차단: %s | cmd: %.80s | pattern: %s",
session_id, body.command, danger)
cmd_log = {
"seq": len(_commands[session_id]) + 1,
"command": body.command[:200],
"reason": body.reason,
"executed_at": datetime.utcnow().isoformat(),
"result": "BLOCKED",
"error": f"위험 패턴 감지: {danger}",
"username": current_user.username,
}
_commands[session_id].append(cmd_log)
raise HTTPException(403, f"위험 명령어 차단됨: '{danger}' 패턴이 포함되어 있습니다.")
# 실제 실행 (SSH 프록시 연동 — 현재는 시뮬레이션)
cmd_log = {
"seq": len(_commands[session_id]) + 1,
"command": body.command[:200],
"reason": body.reason,
"executed_at": datetime.utcnow().isoformat(),
"result": "SIMULATED",
"output": f"[PAM-SIM] Command recorded: {body.command[:80]}",
"username": current_user.username,
"exit_code": 0,
}
_commands[session_id].append(cmd_log)
logger.info("PAM 명령어 실행: %s | %s | cmd: %.80s",
session_id, current_user.username, body.command)
return {
"session_id": session_id,
"seq": cmd_log["seq"],
"command": body.command,
"result": "SIMULATED",
"output": cmd_log["output"],
"executed_at": cmd_log["executed_at"],
}
@router.get("/sessions/{session_id}/commands")
async def get_command_history(
session_id: str,
current_user: User = Depends(get_current_user),
_db: AsyncSession = Depends(get_db),
):
"""세션 명령어 실행 이력."""
s = _sessions.get(session_id)
if not s:
raise HTTPException(404, f"세션 {session_id}를 찾을 수 없습니다.")
if current_user.role == UserRole.ENGINEER and s["requester"] != current_user.username:
raise HTTPException(403, "본인 세션만 조회할 수 있습니다.")
return {
"session_id": session_id,
"commands": _commands.get(session_id, []),
"total": len(_commands.get(session_id, [])),
}
@router.get("/stats")
async def pam_stats(
current_user: User = Depends(get_current_user),
_db: AsyncSession = Depends(get_db),
):
"""PAM 통계 대시보드."""
if current_user.role not in (UserRole.ADMIN, UserRole.PM):
raise HTTPException(403, "PM/ADMIN 권한이 필요합니다.")
sessions = list(_sessions.values())
by_status = {}
for s in sessions:
st = s["status"]
by_status[st] = by_status.get(st, 0) + 1
total_cmds = sum(len(v) for v in _commands.values())
blocked_cmds = sum(
sum(1 for c in cmds if c.get("result") == "BLOCKED")
for cmds in _commands.values()
)
# 만료 체크
for s in sessions:
if s["status"] == "ACTIVE" and _is_expired(s):
s["status"] = "EXPIRED"
by_status["EXPIRED"] = by_status.get("EXPIRED", 0) + 1
by_status["ACTIVE"] = by_status.get("ACTIVE", 1) - 1
return {
"total_sessions": len(sessions),
"by_status": by_status,
"total_commands": total_cmds,
"blocked_commands": blocked_cmds,
"active_sessions": by_status.get("ACTIVE", 0),
"pending_approvals": by_status.get("PENDING", 0),
}
@router.get("/policies")
async def get_policies(
current_user: User = Depends(get_current_user),
_db: AsyncSession = Depends(get_db),
):
"""PAM 접근 정책 목록."""
if current_user.role not in (UserRole.ADMIN, UserRole.PM):
raise HTTPException(403, "PM/ADMIN 권한이 필요합니다.")
return {
"policies": [
{
"name": "세션 최대 시간",
"value": "8시간",
"description": "특권 세션 1회 최대 사용 시간",
},
{
"name": "승인 필요 역할",
"value": "ENGINEER",
"description": "ENGINEER는 PM/ADMIN 승인 필요. ADMIN은 자동 승인",
},
{
"name": "root 접속 정책",
"value": "금지",
"description": "root SSH 직접 접속 절대 금지 (opsagent 계정 사용)",
},
{
"name": "위험 명령어 차단",
"value": f"{len(_DANGER_PATTERNS)}개 패턴",
"description": "rm -rf /, mkfs, dd, shutdown 등 위험 패턴 자동 차단",
},
{
"name": "감사 로그",
"value": "전체 명령어",
"description": "모든 특권 명령어 실행 시 감사 로그 필수 기록",
},
]
}