feat(security): 보안 3종 — PII 감사로그·공개쓰기 레이트리밋·CI 의존성 스캔
All checks were successful
CI / backend-test (push) Successful in 1m22s
CI / frontend-build (push) Successful in 24s
CI / backend-dep-scan (push) Successful in 31s

SEC-02 PII 감사로그(누가·언제·어디서 개인정보를 조회/내보냈/파기했나):
- AuditLog 엔티티 + AuditLogRepository + AuditService(DB 영속 + slf4j 이중기록)
- MentorController: 명단조회·CSV내보내기·비번재설정·학생파기 4개 민감지점에 기록
- GET /api/mentor/audit-logs + MentorPage에 '개인정보 접근 기록' 표(남기기+보기 짝)
- 미성년 수습생 PII라 개인정보보호법상 접근기록 의무 대응

SEC-03 공개쓰기 레이트리밋(무인증 남용 방어):
- RateLimiterService(범용 고정창, Clock 주입, 메모리) — LoginAttemptService와 별도(단일책임)
- signup(30건/시간/IP: 대량가입·알림폭탄 차단), find-id(15건/10분/IP: 이름열거 차단)
- 초과 시 429. ClientIp 유틸로 rightmost XFF(Caddy 뒤 실제 IP) 공용화(AuthController 중복 제거)

CI 의존성 스캔:
- 프론트 npm audit(--omit=dev --audit-level=high, 비차단), 백엔드 OSV-Scanner v2.4.0(비차단)
- 둘 다 continue-on-error로 '보고 전용' 시작 → 무관한 신규 권고로 CI가 붉어지지 않게

적대적 보안 리뷰(4관점→검증) 확정 4건(전부 LOW) 반영:
- 파기 감사에 대상 아이디/이름 박제(파기 후 id는 dangling → '누구'를 못 알던 문제)
- 가입 한도 10→30(기수 동시 온보딩 대비), 감사 저장 전 로그 선기록(DB실패 시 흔적 유지)
- 고정창 경계 2배 특성 문서화, npm audit 비차단 전환

테스트 29→47(RateLimiter 5·Audit 3·SecurityContract 2 추가), 전부 통과.
운영 검증: audit_log 테이블 생성·감사쓰기(실IP 포함)·find-id 429·스모크 11/11 확인.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
AWESOMEDEV 2026-07-17 18:52:44 +09:00
parent daf3e9ac09
commit e04e93cf18
13 changed files with 743 additions and 37 deletions

View File

@ -60,6 +60,17 @@ jobs:
- name: 의존성 설치
run: cd frontend && npm ci
# 의존성 취약점 스캔 — 배포에 실리는 패키지(--omit=dev)의 high 이상 취약점을 보고.
# 학습 포인트 ①: package-lock.json 기반으로 레지스트리에 온디맨드 질의만 하므로(대용량 DB
# 다운로드 없음) CI가 안정적이다. --omit=dev로 vite/eslint 등 배포에 안 실리는 도구는 제외.
# 학습 포인트 ②: 왜 continue-on-error(비차단)인가? — npm audit는 "코드"가 아니라 "외부
# 권고 DB 상태"에 결합된다. 우리가 한 줄도 안 고쳐도, 어느 날 axios 등에 새 권고가 뜨면
# 차단형이면 무관한 PR까지 전부 CI가 붉어져 머지가 막힌다. 그래서 백엔드 OSV-Scanner와
# 똑같이 도입 초기엔 "보고만" 하고, 팀이 수용목록(allow-list) 운영에 익숙해지면 차단으로 승격한다.
- name: 의존성 취약점 스캔 (npm audit, 비차단)
continue-on-error: true
run: cd frontend && npm audit --omit=dev --audit-level=high
# 린트: 안 쓰는 변수·존재하지 않는 전역·React 훅 오용 등 버그류 검출.
- name: 린트 (ESLint)
run: cd frontend && npm run lint
@ -70,3 +81,25 @@ jobs:
- name: 프론트 빌드 (JSX 오류·깨진 import 검출)
run: cd frontend && npm run build
# ── 백엔드 의존성 취약점 스캔 (OSV-Scanner) — 비차단(보고 전용) ──
# 학습 포인트: 백엔드는 OWASP dependency-check의 NVD 대용량 DB를 클린 러너가 매 실행
# 재다운로드해야 해(레이트리밋·타임아웃) CI를 불안정하게 만든다. 대신 OSV-Scanner는
# osv.dev에 패키지별 온디맨드 질의라 대용량 DB가 없다. 도입 초기라 continue-on-error로
# "보고만" 하고(취약점 있어도 CI는 초록), 팀이 익숙해지면 차단 게이트로 승격한다.
backend-dep-scan:
runs-on: ubuntu-latest
steps:
- name: 코드 받기
uses: actions/checkout@v4
# 릴리스 바이너리를 버전 고정으로 내려받는다(GitHub 릴리스 자산은 영구 보관 → 미래에도 안 깨짐).
- name: OSV-Scanner 설치
continue-on-error: true
run: |
curl -fsSL https://github.com/google/osv-scanner/releases/download/v2.4.0/osv-scanner_linux_amd64 -o /usr/local/bin/osv-scanner
chmod +x /usr/local/bin/osv-scanner
- name: 백엔드 pom.xml 취약점 스캔 (비차단)
continue-on-error: true
run: osv-scanner scan source -r backend

View File

@ -0,0 +1,113 @@
package dev.awesomedev.mirim.domain;
import jakarta.persistence.*;
import java.time.Instant;
/**
* 파일이 하는 :
* "누가, 언제, 어떤 개인정보(PII)를, 어느 IP에서 열람/내보냈/파기했나" 남기는 감사로그 테이블.
*
* 학습 포인트 감사로그가 필요한가?
* 수습생 인적사항(이름·전화·주소·생년월일·비상연락처) 미성년자의 개인정보다.
* 개인정보보호법상 회사는 "누가 개인정보에 접근·제공받았는지" 기록·보관할 의무가 있고,
* 유출 사고가 나면 기록이 "어디서 새어 나갔나" 추적하는 최초의 단서가 된다.
* 특히 명단 CSV 다운로드는 데이터가 서버 (경리 PC·엑셀)으로 나가는 순간이라 반드시 남긴다.
*
* 학습 포인트 엔티티 연관(User) 없이 값만 복사해 두나?
* 감사로그는 "그 시점의 사실" 박제하는 것이다. 나중에 학생이 파기(삭제)돼도
* "예전에 누가 열람했다" 기록은 남아야 한다. User를 FK로 연결하면 학생 삭제
* 기록이 함께 사라지거나 참조가 깨진다. 그래서 actor/targetUserId를 (String/Long)으로 저장한다.
* (덤으로 open-in-view:false 환경에서 LAZY 연관 예외 걱정도 없다.)
*
* 학습 포인트 비밀번호·주민번호 같은 민감정보는 감사로그에도 남기지 않는다.
* "누가 무엇을 했다" 남기되, "그 내용 자체"(비밀번호 ) 남기지 않는다.
*/
@Entity
@Table(name = "audit_log", indexes = @Index(name = "ix_audit_log_created_at", columnList = "createdAt"))
public class AuditLog {
/** 감사로그에 남길 행위의 종류. 문자열로 저장해 사람이 읽고 조회·필터하기 쉽게 한다. */
public enum Action {
EXPORT_STUDENT_CSV, // 수습생 명단 CSV 다운로드 (최고 민감 데이터가 서버 밖으로)
VIEW_STUDENT_ROSTER, // 수습생 인적사항 전체 목록 조회 (JSON)
RESET_STUDENT_PASSWORD, // 멘토가 학생 비밀번호 재설정 (계정 통제)
PURGE_STUDENT // 학생 계정·개인정보 영구 파기 (비가역)
}
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/** 행위자(로그인 아이디). */
@Column(nullable = false, length = 40)
private String actor;
/** 행위자 역할(MENTOR 등) — 나중에 "멘토가 한 일"만 걸러 보기 위해. */
@Column(nullable = false, length = 20)
private String actorRole;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 30)
private Action action;
/** 사람이 읽을 설명(예: "명단 CSV 다운로드", "학생 #3 파기"). 민감값은 넣지 않는다. */
@Column(length = 200)
private String detail;
/** 대상 학생의 id(개별 대상이 있을 때만). 명단 전체 조회처럼 대상이 '전체'면 null. */
@Column
private Long targetUserId;
@Column(nullable = false, length = 45)
private String ip;
@Column(nullable = false)
private Instant createdAt;
protected AuditLog() {
}
public AuditLog(String actor, String actorRole, Action action, String detail,
Long targetUserId, String ip, Instant createdAt) {
this.actor = actor;
this.actorRole = actorRole;
this.action = action;
this.detail = detail;
this.targetUserId = targetUserId;
this.ip = ip;
this.createdAt = createdAt;
}
public Long getId() {
return id;
}
public String getActor() {
return actor;
}
public String getActorRole() {
return actorRole;
}
public Action getAction() {
return action;
}
public String getDetail() {
return detail;
}
public Long getTargetUserId() {
return targetUserId;
}
public String getIp() {
return ip;
}
public Instant getCreatedAt() {
return createdAt;
}
}

View File

@ -0,0 +1,19 @@
package dev.awesomedev.mirim.repository;
import dev.awesomedev.mirim.domain.AuditLog;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
/**
* 파일이 하는 :
* 감사로그를 저장하고, 최근 기록을 최신순으로 읽어오는 저장소.
*
* 학습 포인트: 메서드 이름만으로 쿼리가 만들어진다(Spring Data JPA).
* findTop200ByOrderByCreatedAtDesc "createdAt 내림차순으로 상위 200건".
* 감사로그는 계속 쌓이므로, 화면엔 최근 것만 보여 주고 전체는 필요할 때만 조회한다.
*/
public interface AuditLogRepository extends JpaRepository<AuditLog, Long> {
List<AuditLog> findTop200ByOrderByCreatedAtDesc();
}

View File

@ -0,0 +1,54 @@
package dev.awesomedev.mirim.service;
import dev.awesomedev.mirim.domain.AuditLog;
import dev.awesomedev.mirim.repository.AuditLogRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Clock;
import java.util.List;
/**
* 파일이 하는 :
* 개인정보 접근·처리 "사건" 감사로그 테이블에 남기고, 최근 기록을 읽어 준다.
*
* 학습 포인트 "이중 기록": DB(영속·조회용) + 표준 로그(운영 즉시 확인용).
* DB에 남기면 멘토 화면에서 "누가 언제 열람했나" 조회·필터할 있다.
* 동시에 slf4j로도 찍어, 서버 로그를 보는 사람이 흐름 속에서 바로 알아챌 있다.
* , 비밀번호 같은 민감값은 어느 쪽에도 남기지 않는다(AuditLog 주석 참고).
*/
@Service
public class AuditService {
private static final Logger log = LoggerFactory.getLogger(AuditService.class);
private final AuditLogRepository auditLogRepository;
private final Clock clock;
public AuditService(AuditLogRepository auditLogRepository, Clock clock) {
this.auditLogRepository = auditLogRepository;
this.clock = clock;
}
/** 개인정보 접근·처리 사건 한 건을 기록한다. */
@Transactional
public void record(String actor, String actorRole, AuditLog.Action action,
Long targetUserId, String detail, String ip) {
// 학습 포인트: DB 저장 "전에" 먼저 로그로 남긴다.
// 비가역 행위(파기 ) 본작업이 이미 커밋된 감사가 별도 트랜잭션으로 실행된다.
// 만약 audit_log INSERT가 실패(DB 순단 )하면 감사기록이 통째로 사라질 있는데,
// 저장 전에 로그부터 찍어 두면 최소한 서버 로그엔 "그 사건이 있었다" 남는다.
// (완전한 원자성은 텔레그램 발송 같은 되돌릴 없는 부작용과 충돌하므로 택하지 않는다.)
log.info("감사기록 actor={} role={} action={} target={} ip={} detail={}",
actor, actorRole, action, targetUserId, ip, detail);
auditLogRepository.save(new AuditLog(actor, actorRole, action, detail, targetUserId, ip, clock.instant()));
}
/** 멘토 화면용 — 최근 감사기록(최신순 상위 200건). */
@Transactional(readOnly = true)
public List<AuditLog> recent() {
return auditLogRepository.findTop200ByOrderByCreatedAtDesc();
}
}

View File

@ -0,0 +1,82 @@
package dev.awesomedev.mirim.service;
import org.springframework.stereotype.Service;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 파일이 하는 :
* "같은 키(보통 IP)로 정해진 시간 안에 몇 번까지만 허용"하는 범용 레이트리밋(호출 빈도 제한).
* 무인증으로 열린 쓰기 API(회원가입·아이디찾기) 자동화 남용으로부터 지킨다.
*
* 학습 포인트 로그인 잠금(LoginAttemptService) 따로 두나?
* 로그인 잠금은 "연속 실패" 세어 잠그는 특화 로직이다(성공하면 리셋).
* 여기 레이트리밋은 성공/실패와 무관하게 "호출 횟수 자체" 센다
* 가입 봇이 계정을 5천 만드는 막는 실패 카운트가 아니라 호출 카운트다.
* 문제는 규칙이 달라 클래스를 나눈다(단일 책임).
*
* 학습 포인트 "고정 창(fixed window)" 방식.
* 키마다 "창이 언제 끝나는지(expiresAt)" "그 창에서 몇 번 왔는지(count)" 기억한다.
* 창이 끝나면 다음 호출에서 카운트를 0부터 새로 센다. 구현이 단순하고 규모에 충분하다.
* (정밀한 슬라이딩 윈도/토큰버킷은 매끄럽지만 복잡하다 규모가 필요를 정한다.)
* 알려진 한계: 경계에서는 실효 한도가 2배가 있다. 종료 직전 limit건 +
* 만료 직후 limit건이 짧은 구간에 몰리면 기준 최대 2×limit이 통과한다(유계이므로 방어가
* 무력화되진 않는다). 한도를 정할 "경계에서 최대 2배" 감안해 여유 있게 잡는다.
*
* 학습 포인트 메모리(Map)인가? Clock 주입은 ?
* 기록은 창이 지나면 쓸모없어지고 유실돼도 피해가 없다. 서버 1대면 메모리가 가장 단순·빠르다
* (여러 대가 되면 Redis 공유 저장소로). 시간을 Clock으로 주입받아 테스트에서 만료를
* 실제로 기다리지 않고 검증한다 LoginAttemptService와 같은 설계 원칙.
*/
@Service
public class RateLimiterService {
private final Clock clock;
// ConcurrentHashMap: 여러 요청(스레드) 동시에 만져도 안전한 Map.
private final Map<String, Window> windows = new ConcurrentHashMap<>();
/** 한 키의 현재 창: 이 시각에 창이 끝나고, 지금까지 몇 번 왔나. */
private record Window(Instant expiresAt, int count) {
}
public RateLimiterService(Clock clock) {
this.clock = clock;
}
/**
* bucket(용도 구분)+key(보통 IP) 기준으로, window 동안 limit회까지 허용한다.
* 허용되면 true, 한도를 넘어서면 false를 돌려준다(호출자가 429로 막으면 된다).
*
* 주의: 메서드는 "시도" 셈에 포함한다 넘친 시도도 카운트되지만 어차피 창이
* 끝날 때까지 계속 거부되므로 결과는 같다(창당 최대 limit회 통과).
*/
public boolean tryAcquire(String bucket, String key, int limit, Duration window) {
Instant now = clock.instant();
String composite = bucket + "|" + key;
Window updated = windows.compute(composite, (k, prev) -> {
// 기록이 없거나 창이 이미 끝났으면 창을 연다(카운트 1).
if (prev == null || now.isAfter(prev.expiresAt())) {
return new Window(now.plus(window), 1);
}
// 창이 살아 있으면 카운트만 올린다( 끝나는 시각은 그대로).
return new Window(prev.expiresAt(), prev.count() + 1);
});
purgeIfLarge(now);
return updated.count() <= limit;
}
/**
* 학습 포인트: 메모리에 쌓기만 하는 자료구조는 언젠가 메모리를 먹는다(누수).
* 공격자가 IP를 바꿔가며 수만 두드리면 Map이 커지므로, 일정 크기를 넘으면
* 이미 창이 끝난(만료된) 기록을 청소한다. (우리 규모에선 사실상 돌지만, 습관이다.)
*/
private void purgeIfLarge(Instant now) {
if (windows.size() > 10_000) {
windows.entrySet().removeIf(e -> now.isAfter(e.getValue().expiresAt()));
}
}
}

View File

@ -3,6 +3,7 @@ package dev.awesomedev.mirim.web;
import dev.awesomedev.mirim.domain.User;
import dev.awesomedev.mirim.service.AuthService;
import dev.awesomedev.mirim.service.LoginAttemptService;
import dev.awesomedev.mirim.service.RateLimiterService;
import dev.awesomedev.mirim.web.dto.FindIdRequest;
import dev.awesomedev.mirim.web.dto.FindIdResponse;
import dev.awesomedev.mirim.web.dto.LoginRequest;
@ -23,6 +24,7 @@ import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.context.SecurityContextRepository;
import org.springframework.web.bind.annotation.*;
import java.time.Duration;
import java.util.List;
/**
@ -41,15 +43,29 @@ import java.util.List;
@RequestMapping("/api/auth")
public class AuthController {
// 무인증 공개 쓰기 API의 남용 방어(레이트리밋) 정책.
// 학습 포인트: 정상 사용자에겐 넉넉하고, 자동화 (수천 ) 확실히 막히는 값으로 잡는다.
// 가입: 오리엔테이션 기수(교육장 Wi-Fi 단일 IP) 동시에 가입해도 막히지 않도록
// 30건/시간으로 넉넉히 잡는다 봇의 대량 생성(수천 ) 여전히 차단된다.
// (같은 IP로 NAT되면 여러 명이 IP로 보이므로, 동시 온보딩 인원보다 여유 있게.)
// 아이디찾기: 헷갈려 다시 찾는 허용, 이름을 무한 대입하는 열거는 차단.
private static final int SIGNUP_MAX_PER_HOUR = 30;
private static final Duration SIGNUP_WINDOW = Duration.ofHours(1);
private static final int FIND_ID_MAX = 15;
private static final Duration FIND_ID_WINDOW = Duration.ofMinutes(10);
private final AuthService authService;
private final LoginAttemptService loginAttemptService;
private final RateLimiterService rateLimiter;
private final SecurityContextRepository securityContextRepository;
public AuthController(AuthService authService,
LoginAttemptService loginAttemptService,
RateLimiterService rateLimiter,
SecurityContextRepository securityContextRepository) {
this.authService = authService;
this.loginAttemptService = loginAttemptService;
this.rateLimiter = rateLimiter;
this.securityContextRepository = securityContextRepository;
}
@ -61,7 +77,7 @@ public class AuthController {
// 0) 무차별 대입 방어 비밀번호를 검증하기 "전에" 잠금부터 확인한다.
// 잠긴 상태에서는 맞는 비밀번호를 넣어도 검증 자체를 하지 않는다.
// ( 그러면 공격자가 잠금 중에도 계속 정답 후보를 확인해 있다.)
String clientIp = clientIp(request);
String clientIp = ClientIp.of(request);
if (loginAttemptService.isLocked(loginRequest.username(), clientIp)) {
throw new org.springframework.web.server.ResponseStatusException(
HttpStatus.TOO_MANY_REQUESTS, "로그인 시도가 너무 많습니다. 5분 뒤에 다시 시도해 주세요.");
@ -141,7 +157,15 @@ public class AuthController {
*/
@PostMapping("/signup")
@ResponseStatus(HttpStatus.CREATED) // 자원(계정) 새로 생겼으므로 REST 관례대로 201
public SignupResponse signup(@Valid @RequestBody SignupRequest signupRequest) {
public SignupResponse signup(@Valid @RequestBody SignupRequest signupRequest,
HttpServletRequest request) {
// 무인증 대량 가입 + 멘토 텔레그램 알림 폭탄 방어 IP당 시간당 횟수 제한.
// 학습 포인트: @Valid 검증이 먼저 통과한 코드가 돈다. 검증 실패는 400,
// 한도 초과는 429(TOO_MANY_REQUESTS) 실패 사유마다 알맞은 상태코드를 준다.
if (!rateLimiter.tryAcquire("signup", ClientIp.of(request), SIGNUP_MAX_PER_HOUR, SIGNUP_WINDOW)) {
throw new org.springframework.web.server.ResponseStatusException(
HttpStatus.TOO_MANY_REQUESTS, "가입 시도가 너무 많습니다. 잠시 후 다시 시도해 주세요.");
}
User user = authService.signup(signupRequest);
return SignupResponse.from(user);
}
@ -152,7 +176,14 @@ public class AuthController {
* 마스킹하는지는 AuthService.findUsernamesByName 주석 참고 보안 검토의 실제 교훈이 담겨 있다.
*/
@PostMapping("/find-id")
public FindIdResponse findId(@Valid @RequestBody FindIdRequest findIdRequest) {
public FindIdResponse findId(@Valid @RequestBody FindIdRequest findIdRequest,
HttpServletRequest request) {
// 이름 열거(enumeration) 방어 임의 이름을 무한 대입해 등록 여부·마스킹 아이디를
// 수집하는 막는다. IP당 조회 횟수를 제한한다.
if (!rateLimiter.tryAcquire("find-id", ClientIp.of(request), FIND_ID_MAX, FIND_ID_WINDOW)) {
throw new org.springframework.web.server.ResponseStatusException(
HttpStatus.TOO_MANY_REQUESTS, "요청이 너무 많습니다. 잠시 후 다시 시도해 주세요.");
}
return new FindIdResponse(authService.findUsernamesByName(findIdRequest.name()));
}
@ -172,30 +203,4 @@ public class AuthController {
changePasswordRequest.newPassword());
// 반환할 내용이 없으므로 본문 없이 200 OK만 내려간다.
}
/**
* 요청을 보낸 클라이언트의 IP를 알아낸다 (로그인 잠금의 키로 쓴다).
*
* 학습 포인트: 우리 백엔드 앞에는 Caddy(리버스 프록시) 있다. 그래서
* request.getRemoteAddr() 항상 "Caddy의 IP" 이걸 키로 쓰면 세계 사용자가
* 명으로 묶여 버린다. 진짜 클라이언트 IP는 Caddy가 X-Forwarded-For 헤더에 담아 준다.
*
* 실제로 났던 취약점 "맨 앞이 클라이언트" 함정이었다.
* X-Forwarded-For는 클라이언트가 임의로 넣어 보낼 있는 헤더다. 프록시(Caddy)
* 값을 지우지 않고 "실제 연결 IP를 뒤에 덧붙인다". 그래서 백엔드가 보는 값은
* [클라이언트가 위조한 ] , [Caddy가 진짜 IP]
* 된다. (leftmost) 믿으면 공격자가 요청마다 가짜 IP를 넣어
* 잠금 카운트를 매번 키로 흩뿌려 무차별 대입 잠금을 통째로 우회할 있었다.
* 신뢰할 있는 "프록시가 직접 덧붙인" (rightmost) 값이다.
* (프록시가 Caddy 하나뿐이라 값이 실제 클라이언트 IP다. 프록시가 여러 겹이면
* "신뢰하는 프록시 수만큼 뒤에서" 세어야 한다 수를 아는 것이 핵심이다.)
*/
private String clientIp(HttpServletRequest request) {
String forwarded = request.getHeader("X-Forwarded-For");
if (forwarded != null && !forwarded.isBlank()) {
String[] hops = forwarded.split(",");
return hops[hops.length - 1].trim(); // = Caddy가 붙인 실제 IP (위조 불가)
}
return request.getRemoteAddr();
}
}

View File

@ -0,0 +1,41 @@
package dev.awesomedev.mirim.web;
import jakarta.servlet.http.HttpServletRequest;
/**
* 파일이 하는 :
* 요청을 보낸 진짜 클라이언트 IP를 알아낸다 로그인 잠금·레이트리밋·감사로그가 공통으로 쓴다.
*
* 학습 포인트: 우리 백엔드 앞에는 Caddy(리버스 프록시) 있다. 그래서
* request.getRemoteAddr() 항상 "Caddy의 IP" 이걸 키로 쓰면 세계 사용자가
* 명으로 묶여 버린다. 진짜 클라이언트 IP는 Caddy가 X-Forwarded-For 헤더에 담아 준다.
*
* 실제로 났던 취약점 "맨 앞이 클라이언트" 함정이었다.
* X-Forwarded-For는 클라이언트가 임의로 넣어 보낼 있는 헤더다. 프록시(Caddy)
* 값을 지우지 않고 "실제 연결 IP를 뒤에 덧붙인다". 그래서 백엔드가 보는 값은
* [클라이언트가 위조한 ] , [Caddy가 진짜 IP]
* 된다. (leftmost) 믿으면 공격자가 요청마다 가짜 IP를 넣어
* 잠금/레이트리밋 카운트를 매번 키로 흩뿌려 방어를 통째로 우회할 있었다.
* 신뢰할 있는 "프록시가 직접 덧붙인" (rightmost) 값이다.
* (프록시가 Caddy 하나뿐이라 값이 실제 클라이언트 IP다. 프록시가 여러 겹이면
* "신뢰하는 프록시 수만큼 뒤에서" 세어야 한다 수를 아는 것이 핵심이다.)
*
* 학습 포인트 : 유틸로 뽑았나
* 규칙(rightmost) 여러 곳에서 각자 구현하면, 곳만 leftmost로 잘못 짜도 지점이
* 우회 구멍이 된다. "보안 규칙은 한 곳에만" 두어 모두가 같은 규칙을 쓰게 한다.
*/
public final class ClientIp {
private ClientIp() {
// 인스턴스를 만들 이유가 없는 유틸리티 클래스 생성 막기.
}
public static String of(HttpServletRequest request) {
String forwarded = request.getHeader("X-Forwarded-For");
if (forwarded != null && !forwarded.isBlank()) {
String[] hops = forwarded.split(",");
return hops[hops.length - 1].trim(); // = Caddy가 붙인 실제 IP (위조 불가)
}
return request.getRemoteAddr();
}
}

View File

@ -1,8 +1,10 @@
package dev.awesomedev.mirim.web;
import dev.awesomedev.mirim.domain.AuditLog;
import dev.awesomedev.mirim.domain.CodingSubmission;
import dev.awesomedev.mirim.domain.Submission;
import dev.awesomedev.mirim.repository.UserRepository;
import dev.awesomedev.mirim.service.AuditService;
import dev.awesomedev.mirim.service.AuthService;
import dev.awesomedev.mirim.service.CodingService;
import dev.awesomedev.mirim.service.CourseProgressService;
@ -11,18 +13,21 @@ import dev.awesomedev.mirim.service.QuizService;
import dev.awesomedev.mirim.service.StudentProfileService;
import dev.awesomedev.mirim.service.StudentPurgeService;
import dev.awesomedev.mirim.service.SubmissionService;
import dev.awesomedev.mirim.web.dto.AuditLogResponse;
import dev.awesomedev.mirim.web.dto.ChecklistProgressOverviewResponse;
import dev.awesomedev.mirim.web.dto.FeedbackRequest;
import dev.awesomedev.mirim.web.dto.MentorResetPasswordRequest;
import dev.awesomedev.mirim.web.dto.MentorSubmissionResponse;
import dev.awesomedev.mirim.web.dto.StudentCourseProgressResponse;
import dev.awesomedev.mirim.web.dto.StudentProfileResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.nio.charset.StandardCharsets;
@ -51,6 +56,7 @@ public class MentorController {
private final StudentProfileService studentProfileService;
private final StudentPurgeService studentPurgeService;
private final UserRepository userRepository;
private final AuditService auditService;
public MentorController(SubmissionService submissionService,
CourseProgressService courseProgressService,
@ -60,7 +66,8 @@ public class MentorController {
AuthService authService,
StudentProfileService studentProfileService,
StudentPurgeService studentPurgeService,
UserRepository userRepository) {
UserRepository userRepository,
AuditService auditService) {
this.submissionService = submissionService;
this.courseProgressService = courseProgressService;
this.progressService = progressService;
@ -70,6 +77,26 @@ public class MentorController {
this.studentProfileService = studentProfileService;
this.studentPurgeService = studentPurgeService;
this.userRepository = userRepository;
this.auditService = auditService;
}
/**
* 개인정보 접근·처리 사건을 감사로그에 남긴다.
*
* 학습 포인트: 행위자·IP를 세션(Authentication) 요청(HttpServletRequest)에서 얻는다.
* Authentication.getName() 로그인 아이디, 권한 목록에서 역할(ROLE_ 접두어 제거) 뽑는다.
* 여기선 DB를 다시 조회하지 않는다 로그인 이미 검증된 세션 정보라 믿을 있고,
* 감사 남기려고 매번 DB를 때리는 낭비를 피한다.
*/
private void audit(AuditLog.Action action, Long targetUserId, String detail,
Authentication authentication, HttpServletRequest request) {
String actor = authentication.getName();
String role = authentication.getAuthorities().stream()
.map(a -> a.getAuthority())
.findFirst()
.map(a -> a.startsWith("ROLE_") ? a.substring("ROLE_".length()) : a)
.orElse("UNKNOWN");
auditService.record(actor, role, action, targetUserId, detail, ClientIp.of(request));
}
/** GET /api/mentor/submissions — 전체 제출물 + 학생 이름 + 과제 제목 (최신순) */
@ -135,10 +162,14 @@ public class MentorController {
/** GET /api/mentor/students — 수습생 인적사항 명단 (화면 표시용) */
@GetMapping("/students")
public List<StudentProfileResponse> students() {
return studentProfileService.allProfiles().stream()
public List<StudentProfileResponse> students(Authentication authentication, HttpServletRequest request) {
List<StudentProfileResponse> profiles = studentProfileService.allProfiles().stream()
.map(StudentProfileResponse::from)
.toList();
// 수습생의 PII(주소·연락처·생년월일 ) 번에 조회하는 지점이라 감사로그를 남긴다.
audit(AuditLog.Action.VIEW_STUDENT_ROSTER, null,
"수습생 인적사항 명단 조회 (" + profiles.size() + "명)", authentication, request);
return profiles;
}
/**
@ -150,8 +181,12 @@ public class MentorController {
* 헤더가 없으면 브라우저는 CSV를 그냥 화면에 텍스트로 펼쳐 버린다.
*/
@GetMapping("/students/export.csv")
public ResponseEntity<Resource> exportCsv() {
public ResponseEntity<Resource> exportCsv(Authentication authentication, HttpServletRequest request) {
byte[] bytes = studentProfileService.exportCsv().getBytes(StandardCharsets.UTF_8);
// 최우선 감사 지점 수습생 PII가 파일로 서버 (경리 PC·엑셀)으로 나가는 순간.
// 유출 사고 "누가 언제 명단 전체를 내려받았나" 최초 추적 단서가 된다.
audit(AuditLog.Action.EXPORT_STUDENT_CSV, null,
"수습생 명단 CSV 다운로드", authentication, request);
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"mirim-students.csv\"")
@ -169,8 +204,16 @@ public class MentorController {
*/
@PostMapping("/users/{id}/reset-password")
public void resetStudentPassword(@PathVariable Long id,
@Valid @RequestBody MentorResetPasswordRequest request) {
@Valid @RequestBody MentorResetPasswordRequest request,
Authentication authentication,
HttpServletRequest httpRequest) {
authService.resetPasswordByMentor(id, request.newPassword());
// 남의 계정 통제권(비밀번호) 바꾸는 민감 행위 누가 누구 것을 바꿨는지 남긴다.
// 대상의 아이디를 함께 박제한다(숫자 id만으론 나중에 '누구'인지 알기 어렵다).
// (바꾼 비밀번호 값은 절대 남기지 않는다.)
String who = userRepository.findById(id).map(u -> u.getUsername()).orElse("id=" + id);
audit(AuditLog.Action.RESET_STUDENT_PASSWORD, id,
"학생 #" + id + " (" + who + ") 비밀번호 재설정", authentication, httpRequest);
}
/**
@ -181,8 +224,33 @@ public class MentorController {
* 의도를 HTTP 동사로 분명히 한다. 동의 화면에서 약속한 "파기" 실제로 지키는 손이다.
*/
@DeleteMapping("/students/{id}")
public void purgeStudent(@PathVariable Long id) {
public void purgeStudent(@PathVariable Long id, Authentication authentication, HttpServletRequest request) {
// 파기 "전에" 대상의 신원(아이디/이름) 확보한다.
// 학습 포인트: purge() User 자체를 지우므로, 파기 후엔 id가 아무도 가리키지 않는
// (dangling) 된다. 그때 감사기록에 숫자 id만 있으면 "누구를 지웠나" 영영 없다.
// 사고 조사에서 가장 답이 필요한 순간이므로, 지우기 전에 아이디/이름을 붙잡아 박제한다.
String who = userRepository.findById(id)
.map(u -> u.getUsername() + " / " + u.getName())
.orElse("id=" + id);
studentPurgeService.purge(id);
// 비가역 파기 되돌릴 없으므로 "언제 누가 누구를 지웠나" 반드시 기록으로 남긴다.
// (대상 학생이 지워져도 감사기록은 값으로 복사돼 남는다 AuditLog 설계 참고.)
audit(AuditLog.Action.PURGE_STUDENT, id,
"학생 #" + id + " (" + who + ") 계정·개인정보 영구 파기", authentication, request);
}
/**
* GET /api/mentor/audit-logs 개인정보 접근·처리 감사기록 (최신순 상위 200건).
*
* 학습 포인트: 감사로그는 "남기기" 해선 반쪽이다. 멘토가 실제로 들여다볼 있어야
* "누가 명단을 자주 내려받나" 같은 이상 징후를 알아챈다. 남기기(record) 보기(view)
* 짝을 이뤄야 감사가 살아 있는 통제가 된다.
*/
@GetMapping("/audit-logs")
public List<AuditLogResponse> auditLogs() {
return auditService.recent().stream()
.map(AuditLogResponse::from)
.toList();
}
/** POST /api/mentor/submissions/{id}/feedback — 피드백 등록, 상태는 REVIEWED로 변경 */

View File

@ -0,0 +1,33 @@
package dev.awesomedev.mirim.web.dto;
import dev.awesomedev.mirim.domain.AuditLog;
/**
* 파일이 하는 :
* 감사로그 건을 화면(멘토) 내려줄 형태로 변환한 응답 DTO.
*
* 학습 포인트: 엔티티(AuditLog) 그대로 반환하지 않고 DTO로 변환한다.
* 시각(Instant) 문자열로, action(enum) 이름 문자열로 프론트가 다루기 쉽게 정돈한다.
*/
public record AuditLogResponse(
Long id,
String actor,
String actorRole,
String action,
String detail,
Long targetUserId,
String ip,
String createdAt) {
public static AuditLogResponse from(AuditLog a) {
return new AuditLogResponse(
a.getId(),
a.getActor(),
a.getActorRole(),
a.getAction().name(),
a.getDetail(),
a.getTargetUserId(),
a.getIp(),
a.getCreatedAt().toString());
}
}

View File

@ -0,0 +1,75 @@
package dev.awesomedev.mirim.service;
import dev.awesomedev.mirim.domain.AuditLog;
import dev.awesomedev.mirim.repository.AuditLogRepository;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 파일이 하는 :
* 감사서비스가 "무엇을, 어떤 값으로" 저장하는지 검증한다.
*
* 학습 포인트 ArgumentCaptor: 가짜 저장소(mock) "무엇이 넘어왔는지" 붙잡아 확인한다.
* 실제 DB 없이도 "record()가 올바른 AuditLog를 만들어 save에 넘겼는가" 정확히 검증할 있다.
* 시각은 고정 Clock으로 못박아 결정적으로 테스트한다.
*/
class AuditServiceTest {
private static final Instant FIXED = Instant.parse("2026-09-01T09:00:00Z");
private final Clock clock = Clock.fixed(FIXED, ZoneOffset.UTC);
private final AuditLogRepository repository = mock(AuditLogRepository.class);
private final AuditService service = new AuditService(repository, clock);
@Test
@DisplayName("record()는 넘겨받은 값 그대로 + 현재시각으로 감사로그를 저장한다")
void record_saves_audit_log_with_given_values() {
service.record("mentor1", "MENTOR", AuditLog.Action.EXPORT_STUDENT_CSV,
null, "수습생 명단 CSV 다운로드", "203.0.113.9");
ArgumentCaptor<AuditLog> captor = ArgumentCaptor.forClass(AuditLog.class);
verify(repository).save(captor.capture());
AuditLog saved = captor.getValue();
assertEquals("mentor1", saved.getActor());
assertEquals("MENTOR", saved.getActorRole());
assertEquals(AuditLog.Action.EXPORT_STUDENT_CSV, saved.getAction());
assertEquals("수습생 명단 CSV 다운로드", saved.getDetail());
assertEquals("203.0.113.9", saved.getIp());
assertEquals(FIXED, saved.getCreatedAt()); // 고정 시계의 시각이 박힌다
}
@Test
@DisplayName("파기 같은 개별 대상 행위는 targetUserId를 함께 남긴다")
void record_keeps_target_user_id() {
service.record("mentor1", "MENTOR", AuditLog.Action.PURGE_STUDENT,
42L, "학생 #42 파기", "203.0.113.9");
ArgumentCaptor<AuditLog> captor = ArgumentCaptor.forClass(AuditLog.class);
verify(repository).save(captor.capture());
assertEquals(42L, captor.getValue().getTargetUserId());
}
@Test
@DisplayName("recent()는 저장소의 최신순 조회를 그대로 돌려준다")
void recent_delegates_to_repository() {
AuditLog one = new AuditLog("mentor1", "MENTOR", AuditLog.Action.VIEW_STUDENT_ROSTER,
"명단 조회", null, "203.0.113.9", FIXED);
when(repository.findTop200ByOrderByCreatedAtDesc()).thenReturn(List.of(one));
List<AuditLog> result = service.recent();
assertEquals(1, result.size());
assertEquals(AuditLog.Action.VIEW_STUDENT_ROSTER, result.get(0).getAction());
}
}

View File

@ -0,0 +1,97 @@
package dev.awesomedev.mirim.service;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import static org.junit.jupiter.api.Assertions.*;
/**
* 파일이 하는 :
* 범용 레이트리밋(호출 빈도 제한) 규칙을 검증한다.
*
* 학습 포인트 가짜 시계(MutableClock) "창이 지나면 리셋" 실제로 기다리지 않고 검증한다.
* (LoginAttemptServiceTest와 같은 기법. 시간을 주입받게 설계한 덕분이다.)
*/
class RateLimiterServiceTest {
/** 테스트용 시계: advance()로 시간을 앞당긴다. */
static class MutableClock extends Clock {
private Instant now = Instant.parse("2026-09-01T09:00:00Z");
void advance(Duration duration) {
now = now.plus(duration);
}
@Override
public Instant instant() {
return now;
}
@Override
public java.time.ZoneId getZone() {
return ZoneOffset.UTC;
}
@Override
public Clock withZone(java.time.ZoneId zone) {
return this;
}
}
private final MutableClock clock = new MutableClock();
private final RateLimiterService service = new RateLimiterService(clock);
private static final Duration WINDOW = Duration.ofMinutes(10);
@Test
@DisplayName("한도까지는 허용하고, 한도를 넘으면 거부한다")
void allows_up_to_limit_then_denies() {
for (int i = 0; i < 3; i++) {
assertTrue(service.tryAcquire("signup", "1.2.3.4", 3, WINDOW), (i + 1) + "번째는 허용");
}
assertFalse(service.tryAcquire("signup", "1.2.3.4", 3, WINDOW), "4번째는 거부");
}
@Test
@DisplayName("다른 키(IP)는 서로 카운트가 섞이지 않는다")
void keys_are_independent() {
assertTrue(service.tryAcquire("signup", "1.1.1.1", 1, WINDOW));
assertFalse(service.tryAcquire("signup", "1.1.1.1", 1, WINDOW)); // IP는 한도
assertTrue(service.tryAcquire("signup", "2.2.2.2", 1, WINDOW)); // 다른 IP는 멀쩡
}
@Test
@DisplayName("같은 IP라도 용도(bucket)가 다르면 따로 센다")
void buckets_are_independent() {
assertTrue(service.tryAcquire("signup", "1.2.3.4", 1, WINDOW));
assertFalse(service.tryAcquire("signup", "1.2.3.4", 1, WINDOW));
assertTrue(service.tryAcquire("find-id", "1.2.3.4", 1, WINDOW)); // 다른 bucket은 별개
}
@Test
@DisplayName("창(window)이 지나면 카운트가 리셋되어 다시 허용된다")
void resets_after_window() {
assertTrue(service.tryAcquire("signup", "1.2.3.4", 1, WINDOW));
assertFalse(service.tryAcquire("signup", "1.2.3.4", 1, WINDOW)); // 한도
clock.advance(WINDOW.plusSeconds(1)); // 창이 지나간다
assertTrue(service.tryAcquire("signup", "1.2.3.4", 1, WINDOW)); // 창에서 다시 허용
}
@Test
@DisplayName("한도 초과가 창을 연장시키지 않는다 — 넘쳐도 창은 제때 끝난다")
void overflow_does_not_extend_window() {
assertTrue(service.tryAcquire("signup", "1.2.3.4", 1, WINDOW));
// 중간에 계속 두드려도(모두 거부됨)
clock.advance(Duration.ofMinutes(5));
assertFalse(service.tryAcquire("signup", "1.2.3.4", 1, WINDOW));
// 최초 시작 기준 10분이 지나면 리셋되어야 한다
clock.advance(Duration.ofMinutes(5).plusSeconds(1));
assertTrue(service.tryAcquire("signup", "1.2.3.4", 1, WINDOW));
}
}

View File

@ -3,12 +3,14 @@ package dev.awesomedev.mirim.web;
import dev.awesomedev.mirim.config.SecurityConfig;
import dev.awesomedev.mirim.domain.User;
import dev.awesomedev.mirim.repository.UserRepository;
import dev.awesomedev.mirim.service.AuditService;
import dev.awesomedev.mirim.service.AuthService;
import dev.awesomedev.mirim.service.CodingService;
import dev.awesomedev.mirim.service.CourseProgressService;
import dev.awesomedev.mirim.service.LoginAttemptService;
import dev.awesomedev.mirim.service.ProgressService;
import dev.awesomedev.mirim.service.QuizService;
import dev.awesomedev.mirim.service.RateLimiterService;
import dev.awesomedev.mirim.service.StudentProfileService;
import dev.awesomedev.mirim.service.StudentPurgeService;
import dev.awesomedev.mirim.service.SubmissionService;
@ -31,6 +33,8 @@ import java.util.List;
import java.util.Map;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
@ -65,6 +69,8 @@ class SecurityContractTest {
// 컨트롤러들이 의존하는 서비스는 전부 가짜로 끼운다(보안 규칙만 보려는 것이므로).
@MockitoBean private AuthService authService;
@MockitoBean private LoginAttemptService loginAttemptService;
@MockitoBean private RateLimiterService rateLimiterService;
@MockitoBean private AuditService auditService;
@MockitoBean private CodingService codingService;
@MockitoBean private SubmissionService submissionService;
@MockitoBean private CourseProgressService courseProgressService;
@ -155,4 +161,27 @@ class SecurityContractTest {
// permitAll이므로 인증 차단(401) 아니어야 한다. (검증 실패로 400이 나든, 200이 나든 무방)
org.junit.jupiter.api.Assertions.assertNotEquals(401, statusCode);
}
// 공개 쓰기 레이트리밋(SEC-03) 배선 검증
// 학습 포인트: 레이트리밋이 "컨트롤러에 실제로 연결됐는지" 검증한다.
// 한도 초과(tryAcquire=false) 429가 나오고, 여유(true) 서비스까지 도달해야 한다.
@Test
@DisplayName("아이디 찾기: 레이트리밋 한도 초과면 429")
void findId_rate_limited_returns_429() throws Exception {
when(rateLimiterService.tryAcquire(anyString(), anyString(), anyInt(), any())).thenReturn(false);
mockMvc.perform(post("/api/auth/find-id")
.contentType(MediaType.APPLICATION_JSON).content("{\"name\":\"홍길동\"}"))
.andExpect(status().isTooManyRequests());
}
@Test
@DisplayName("아이디 찾기: 레이트리밋 여유가 있으면 통과해 서비스까지 도달(200)")
void findId_within_limit_reaches_service() throws Exception {
when(rateLimiterService.tryAcquire(anyString(), anyString(), anyInt(), any())).thenReturn(true);
when(authService.findUsernamesByName(any())).thenReturn(List.of("ho****1"));
mockMvc.perform(post("/api/auth/find-id")
.contentType(MediaType.APPLICATION_JSON).content("{\"name\":\"홍길동\"}"))
.andExpect(status().isOk());
}
}

View File

@ -328,12 +328,56 @@ function QuizResultsTable({ quizResults }) {
);
}
// (action) . enum 1:1 .
const AUDIT_ACTION_LABEL = {
EXPORT_STUDENT_CSV: '명단 CSV 다운로드',
VIEW_STUDENT_ROSTER: '명단 조회',
RESET_STUDENT_PASSWORD: '비밀번호 재설정',
PURGE_STUDENT: '학생 파기',
};
/**
* 개인정보 접근 감사기록 .
* 학습 포인트: 감사로그는 "남기기"만으로는 반쪽이다. 멘토가 여기서 실제로 들여다볼 있어야
* "누가 명단을 자주 내려받나" 같은 이상 징후를 알아챈다 남기기와 보기가 짝을 이뤄야 통제가 산다.
*/
function AuditLogTable({ logs }) {
return (
<Card style={{ padding: 0, overflow: 'hidden' }}>
{/* table--nowrap: 시각·IP가 세로로 꺾이면 못 읽는다 — 가로 스크롤로 본다 */}
<table className="table table--nowrap" style={{ minWidth: 640 }}>
<thead>
<tr>
<th>시각</th>
<th>행위자</th>
<th>행위</th>
<th>내용</th>
<th>IP</th>
</tr>
</thead>
<tbody>
{logs.map((logRow) => (
<tr key={logRow.id}>
<td className="muted">{new Date(logRow.createdAt).toLocaleString('ko-KR')}</td>
<td style={{ fontWeight: 600 }}>{logRow.actor}</td>
<td>{AUDIT_ACTION_LABEL[logRow.action] || logRow.action}</td>
<td>{logRow.detail}</td>
<td className="muted" style={{ fontVariantNumeric: 'tabular-nums' }}>{logRow.ip}</td>
</tr>
))}
</tbody>
</table>
</Card>
);
}
export default function MentorPage() {
const [submissions, setSubmissions] = useState([]);
const [students, setStudents] = useState([]); //
const [checklistOverview, setChecklistOverview] = useState(null); //
const [quizResults, setQuizResults] = useState([]); //
const [roster, setRoster] = useState([]); //
const [auditLogs, setAuditLogs] = useState([]); //
const [loadError, setLoadError] = useState(false); //
const [loading, setLoading] = useState(true);
const [selectedId, setSelectedId] = useState(null); // id
@ -352,14 +396,16 @@ export default function MentorPage() {
client.get('/mentor/checklist-progress'),
client.get('/mentor/quiz-results'),
client.get('/mentor/students'),
client.get('/mentor/audit-logs'),
])
.then(([subsRes, progressRes, checklistRes, quizRes, rosterRes]) => {
.then(([subsRes, progressRes, checklistRes, quizRes, rosterRes, auditRes]) => {
if (subsRes.status === 'fulfilled') setSubmissions(subsRes.value.data);
if (progressRes.status === 'fulfilled') setStudents(progressRes.value.data);
if (checklistRes.status === 'fulfilled') setChecklistOverview(checklistRes.value.data);
if (quizRes.status === 'fulfilled') setQuizResults(quizRes.value.data);
if (rosterRes.status === 'fulfilled') setRoster(rosterRes.value.data);
const anyFailed = [subsRes, progressRes, checklistRes, quizRes, rosterRes].some(
if (auditRes.status === 'fulfilled') setAuditLogs(auditRes.value.data);
const anyFailed = [subsRes, progressRes, checklistRes, quizRes, rosterRes, auditRes].some(
(r) => r.status === 'rejected',
);
setLoadError(anyFailed);
@ -574,6 +620,17 @@ export default function MentorPage() {
</form>
</Card>
)}
{/* ── 개인정보 접근 기록 (감사로그) ── */}
<h2 className="section-title" style={{ marginTop: 24 }}>개인정보 접근 기록</h2>
<p className="page-desc" style={{ marginTop: -4 }}>
수습생 개인정보를 누가·언제·어디서 조회/내보냈/파기했는지 기록입니다(최근 200).
</p>
{auditLogs.length === 0 ? (
<p className="empty">아직 기록이 없어요. (명단 조회·CSV 다운로드 등을 하면 여기에 남습니다)</p>
) : (
<AuditLogTable logs={auditLogs} />
)}
</div>
);
}