---
name: manager-security
description: >
  GUARDiA Manager의 인증, 권한 제어, 보안 설정을 구현하는 스킬.
  GUARDiA ITSM JWT 재활용, React Route Guard, API Key 관리 UI,
  감사 로그 시각화를 포함한다.
  트리거: 인증 구현, 로그인 페이지, Route Guard, JWT 검증,
  API Key 발급/관리, 감사 로그 화면, 보안 설정 요청 시.
---

# GUARDiA Manager 보안 구현 스킬

## 인증 아키텍처

GUARDiA Manager는 별도 인증 서버 없이 **GUARDiA ITSM JWT를 공유**한다.

```
1. 사용자 → Manager 로그인 페이지
2. POST /api/auth/login (GUARDiA ITSM 8001)
3. JWT 수신 → sessionStorage 저장 (localStorage 금지)
4. 이후 모든 요청: Authorization: Bearer {token}
5. GUARDiA ITSM과 Manager Backend 양쪽에서 동일 JWT 검증
```

## useAuth 훅

```typescript
// frontend/src/hooks/useAuth.ts
import { useState, useEffect, createContext, useContext } from 'react';
import { guardiaApi } from '../api/guardiaClient';

interface AuthState {
  user: { username: string; role: string; display_name: string } | null;
  token: string | null;
  loading: boolean;
}

export function useAuth() {
  const [state, setState] = useState<AuthState>({
    user: null, token: sessionStorage.getItem('guardia_token'), loading: true
  });

  useEffect(() => {
    const token = sessionStorage.getItem('guardia_token');
    if (!token) { setState(s => ({ ...s, loading: false })); return; }
    guardiaApi.get('/api/auth/me')
      .then(res => setState({ user: res.data, token, loading: false }))
      .catch(() => {
        sessionStorage.removeItem('guardia_token');
        setState({ user: null, token: null, loading: false });
      });
  }, []);

  const login = async (username: string, password: string) => {
    const res = await guardiaApi.post('/api/auth/login',
      new URLSearchParams({ username, password }),
      { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
    );
    const { access_token, user } = res.data;
    sessionStorage.setItem('guardia_token', access_token);
    setState({ user, token: access_token, loading: false });
    return user;
  };

  const logout = () => {
    sessionStorage.removeItem('guardia_token');
    setState({ user: null, token: null, loading: false });
    window.location.href = '/login';
  };

  return { ...state, login, logout };
}
```

## Route Guard 컴포넌트

```tsx
// components/common/ProtectedRoute.tsx
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '../../hooks/useAuth';

const ADMIN_ONLY_PATHS = [
  '/api-keys', '/config', '/config/env', '/config/nginx'
];

export function ProtectedRoute({ children }: { children: React.ReactNode }) {
  const { user, token, loading } = useAuth();
  const location = useLocation();

  if (loading) return <div style={{ padding: 40 }}>인증 확인 중...</div>;
  if (!token || !user) return <Navigate to="/login" state={{ from: location }} replace />;

  // admin 전용 페이지 접근 제어
  if (ADMIN_ONLY_PATHS.some(p => location.pathname.startsWith(p))
      && user.role !== 'admin') {
    return <div style={{ padding: 40, color: '#ef4444' }}>
      관리자 권한이 필요합니다.
    </div>;
  }

  return <>{children}</>;
}
```

## API Key 관리 화면 (M-05)

NCloud 콘솔의 "API 인증키 관리" 화면을 참조:
- 테이블: 키 이름, 스코프, 마지막 사용, 만료일, 상태
- 발급: 슬라이드 패널 → 이름/스코프/IP 제한 입력 → 발급 시 키 1회 표시
- 비활성화: 확인 모달 → `DELETE /api/external/keys/{id}`

```tsx
// pages/ApiKeys.tsx 핵심 구조
export function ApiKeys() {
  const { data, loading, refetch } = useGuardiaApi<APIKey[]>('/api/external/keys');
  const [creating, setCreating] = useState(false);
  const [newKey, setNewKey] = useState<string | null>(null);

  const handleCreate = async (form: { name: string; scopes: string; expires_days: number }) => {
    const res = await guardiaApi.post('/api/external/keys', form);
    setNewKey(res.data.api_key);   // 1회만 노출
    refetch();
  };

  return (
    <>
      {/* 발급된 키 1회 노출 모달 */}
      {newKey && (
        <div className="one-time-key-modal">
          <h4>⚠️ API Key가 발급되었습니다 — 지금만 확인 가능합니다</h4>
          <code style={{ userSelect: 'all', background: '#f0f2f5', padding: '10px 14px',
            borderRadius: 6, display: 'block', wordBreak: 'break-all' }}>{newKey}</code>
          <button onClick={() => { navigator.clipboard.writeText(newKey); }}>복사</button>
          <button onClick={() => setNewKey(null)}>확인 완료</button>
        </div>
      )}
      <DataTable
        columns={[
          { key: 'name', header: '키 이름' },
          { key: 'scopes', header: '권한' },
          { key: 'use_count', header: '사용 횟수' },
          { key: 'is_active', header: '상태',
            render: r => <StatusBadge status={r.is_active ? 'running' : 'stopped'} /> },
          { key: 'expires_at', header: '만료일' },
        ]}
        data={data ?? []}
        loading={loading}
        actions={<button onClick={() => setCreating(true)}>+ API Key 발급</button>}
        selectable
      />
    </>
  );
}
```

## 감사 로그 화면 (M-05)

```tsx
// pages/AuditLog.tsx
// - 타임라인 형태로 감사 이벤트 표시 (NCloud "활동 로그" 참조)
// - 필터: 날짜 범위, 사용자, 액션 유형
// - 데이터: GET /api/audit?page=0&size=50
// - IP Hash 표시 (원본 IP는 ITSM 보안 정책상 제공 안 함)
```

## Manager Backend JWT 검증 (core/auth.py)

```python
# backend/core/auth.py
import os
from fastapi import Depends, HTTPException
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt

SECRET = os.environ.get("GUARDIA_JWT_SECRET", "guardia-jwt-secret-2026-change-me!")
ALGORITHM = "HS256"
oauth2 = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False)

async def verify_guardia_token(token: str = Depends(oauth2)):
    if not token:
        raise HTTPException(status_code=401, detail="인증이 필요합니다.")
    try:
        payload = jwt.decode(token, SECRET, algorithms=[ALGORITHM])
        return payload
    except JWTError:
        raise HTTPException(status_code=401, detail="유효하지 않은 토큰입니다.")
```

## 보안 체크리스트

- [ ] localStorage에 토큰 저장 금지 (sessionStorage 사용)
- [ ] API Key, 비밀번호 화면 표시 마스킹 (`****`)
- [ ] 클립보드 복사 후 즉시 메모리 해제
- [ ] admin 전용 라우트 보호 완료
- [ ] CORS 출처 명시적 설정 (wildcard 금지)
- [ ] 모든 시스템 제어 API에 admin 역할 검증
