- 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>
535 lines
20 KiB
Python
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": "모든 특권 명령어 실행 시 감사 로그 필수 기록",
|
|
},
|
|
]
|
|
}
|