feat(audit): P2 — 관리 행위 감사 로그(AuditLog) 추가

- AuditLog 엔티티/AuditAction(APPROVE/REJECT/BLACKLIST_ADD/BLACKLIST_REMOVE) + AuditService.record
  (호출자 트랜잭션 합류, actor는 SecurityUtils에서 채우되 미인증 컨텍스트는 null 허용).
- 훅: ApprovalService(승인/반려), BlacklistService(추가/해제)에 감사 기록.
- GET /api/admin/audit (ADMIN) 최근 200건 조회 + AuditLogResponse.
- Flyway V2__audit_log.sql (prod). FlywayValidationTest가 V1+V2를 엔티티와 대조.
- 테스트: AuditLogTest(승인/블랙리스트 추가 감사행 검증). 전체 7건 통과.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
unknown
2026-07-03 15:34:08 +09:00
parent 6946bf15bb
commit 6905dcaaff
10 changed files with 289 additions and 1 deletions

View File

@@ -0,0 +1,31 @@
package com.itcenter.acs.controller;
import com.itcenter.acs.dto.ApiResponse;
import com.itcenter.acs.dto.AuditLogResponse;
import com.itcenter.acs.service.AuditService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* Read-only audit trail. Restricted to ADMIN by SecurityConfig (/api/admin/**).
*/
@RestController
@RequestMapping("/api/admin/audit")
@RequiredArgsConstructor
public class AuditController {
private final AuditService auditService;
@GetMapping
public ResponseEntity<ApiResponse<List<AuditLogResponse>>> recent() {
List<AuditLogResponse> items = auditService.recent().stream()
.map(AuditLogResponse::from)
.toList();
return ResponseEntity.ok(ApiResponse.success(items));
}
}

View File

@@ -0,0 +1,29 @@
package com.itcenter.acs.dto;
import com.itcenter.acs.entity.AuditLog;
import java.time.LocalDateTime;
/** Read model for an audit trail entry. */
public record AuditLogResponse(
Long id,
LocalDateTime at,
Long actorId,
String actorUsername,
String action,
String targetType,
Long targetId,
String detail) {
public static AuditLogResponse from(AuditLog a) {
return new AuditLogResponse(
a.getId(),
a.getCreatedAt(),
a.getActorId(),
a.getActorUsername(),
a.getAction() != null ? a.getAction().name() : null,
a.getTargetType(),
a.getTargetId(),
a.getDetail());
}
}

View File

@@ -0,0 +1,9 @@
package com.itcenter.acs.entity;
/** Auditable administrative actions. */
public enum AuditAction {
APPROVE,
REJECT,
BLACKLIST_ADD,
BLACKLIST_REMOVE
}

View File

@@ -0,0 +1,46 @@
package com.itcenter.acs.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* An audit record of an administrative action (who did what, when, to which target).
* Immutable once written. {@code actorId} is null for system/scheduler-initiated actions.
*/
@Entity
@Table(name = "audit_logs", indexes = {
@Index(name = "idx_audit_created_at", columnList = "createdAt"),
@Index(name = "idx_audit_action", columnList = "action")
})
@Getter
@Setter
@NoArgsConstructor
public class AuditLog extends BaseEntity {
/** User who performed the action; null for system-initiated actions. */
@Column(name = "actor_id")
private Long actorId;
@Column(name = "actor_username", length = 50)
private String actorUsername;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 30)
private AuditAction action;
@Column(name = "target_type", length = 30)
private String targetType;
@Column(name = "target_id")
private Long targetId;
@Column(length = 500)
private String detail;
}

View File

@@ -0,0 +1,10 @@
package com.itcenter.acs.repository;
import com.itcenter.acs.entity.AuditLog;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface AuditLogRepository extends JpaRepository<AuditLog, Long> {
List<AuditLog> findTop200ByOrderByCreatedAtDesc();
}

View File

@@ -32,6 +32,7 @@ public class ApprovalService {
private final UserRepository userRepository;
private final QrService qrService;
private final PassNotifier passNotifier;
private final AuditService auditService;
public VisitRequest approve(Long visitRequestId, Long approverId, String comment) {
return decide(visitRequestId, approverId, comment, ApprovalDecision.APPROVED);
@@ -67,6 +68,14 @@ public class ApprovalService {
approval.setDecidedAt(LocalDateTime.now());
approvalRepository.save(approval);
String visitorName = vr.getVisitor() != null ? vr.getVisitor().getName() : "?";
auditService.record(
decision == ApprovalDecision.APPROVED
? com.itcenter.acs.entity.AuditAction.APPROVE
: com.itcenter.acs.entity.AuditAction.REJECT,
"VISIT_REQUEST", vr.getId(),
"방문자=" + visitorName + (comment != null && !comment.isBlank() ? ", 의견=" + comment : ""));
if (decision == ApprovalDecision.APPROVED) {
notifyVisitor(vr);
}

View File

@@ -0,0 +1,55 @@
package com.itcenter.acs.service;
import com.itcenter.acs.entity.AuditAction;
import com.itcenter.acs.entity.AuditLog;
import com.itcenter.acs.repository.AuditLogRepository;
import com.itcenter.acs.security.SecurityUtils;
import com.itcenter.acs.security.UserPrincipal;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
* Records administrative actions to the audit trail. {@link #record} joins the
* caller's transaction so the action and its audit entry commit atomically.
*/
@Service
@RequiredArgsConstructor
public class AuditService {
private final AuditLogRepository auditLogRepository;
@Transactional
public void record(AuditAction action, String targetType, Long targetId, String detail) {
AuditLog log = new AuditLog();
log.setAction(action);
log.setTargetType(targetType);
log.setTargetId(targetId);
log.setDetail(truncate(detail));
// Best-effort actor resolution — some callers (e.g. schedulers) have no principal.
try {
UserPrincipal principal = SecurityUtils.currentPrincipal();
log.setActorId(principal.getId());
log.setActorUsername(principal.getUsername());
} catch (RuntimeException ignored) {
// system-initiated: leave actor null
}
auditLogRepository.save(log);
}
@Transactional(readOnly = true)
public List<AuditLog> recent() {
return auditLogRepository.findTop200ByOrderByCreatedAtDesc();
}
private static String truncate(String s) {
if (s == null) {
return null;
}
return s.length() <= 500 ? s : s.substring(0, 500);
}
}

View File

@@ -1,6 +1,7 @@
package com.itcenter.acs.service;
import com.itcenter.acs.dto.BlacklistRequest;
import com.itcenter.acs.entity.AuditAction;
import com.itcenter.acs.entity.Blacklist;
import com.itcenter.acs.entity.User;
import com.itcenter.acs.exception.ApiException;
@@ -19,6 +20,7 @@ public class BlacklistService {
private final BlacklistRepository blacklistRepository;
private final UserRepository userRepository;
private final AuditService auditService;
@Transactional(readOnly = true)
public List<Blacklist> listActive() {
@@ -35,7 +37,10 @@ public class BlacklistService {
b.setReason(req.getReason());
b.setActive(true);
b.setCreatedBy(creator);
return blacklistRepository.save(b);
Blacklist saved = blacklistRepository.save(b);
auditService.record(AuditAction.BLACKLIST_ADD, "BLACKLIST", saved.getId(),
"대상=" + saved.getName() + ", 사유=" + saved.getReason());
return saved;
}
/** Soft-deactivate (lift) a block. */
@@ -43,6 +48,8 @@ public class BlacklistService {
Blacklist b = blacklistRepository.findById(id)
.orElseThrow(() -> ApiException.notFound("차단 항목을 찾을 수 없습니다."));
b.setActive(false);
auditService.record(AuditAction.BLACKLIST_REMOVE, "BLACKLIST", b.getId(),
"대상=" + b.getName());
}
/** Returns the matching block reason, or null if not blacklisted. */

View File

@@ -0,0 +1,16 @@
-- 감사 로그: 관리 행위(승인/반려, 블랙리스트 추가/해제) 추적
-- Hibernate ddl-auto=validate 가 검증하므로 AuditLog 엔티티와 컬럼명·타입이 일치해야 한다.
CREATE TABLE audit_logs (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
actor_id BIGINT,
actor_username VARCHAR(50),
action VARCHAR(30) NOT NULL,
target_type VARCHAR(30),
target_id BIGINT,
detail VARCHAR(500)
);
CREATE INDEX idx_audit_created_at ON audit_logs (created_at);
CREATE INDEX idx_audit_action ON audit_logs (action);

View File

@@ -0,0 +1,76 @@
package com.itcenter.acs;
import com.itcenter.acs.dto.BlacklistRequest;
import com.itcenter.acs.entity.AuditAction;
import com.itcenter.acs.entity.User;
import com.itcenter.acs.entity.Visitor;
import com.itcenter.acs.entity.VisitRequest;
import com.itcenter.acs.entity.VisitStatus;
import com.itcenter.acs.repository.AuditLogRepository;
import com.itcenter.acs.repository.UserRepository;
import com.itcenter.acs.repository.VisitRequestRepository;
import com.itcenter.acs.repository.VisitorRepository;
import com.itcenter.acs.service.ApprovalService;
import com.itcenter.acs.service.BlacklistService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.time.LocalDate;
import static org.assertj.core.api.Assertions.assertThat;
/** Approving a request and adding a blacklist entry each leave an audit record. */
@SpringBootTest
class AuditLogTest {
@Autowired ApprovalService approvalService;
@Autowired BlacklistService blacklistService;
@Autowired VisitRequestRepository visitRequestRepository;
@Autowired VisitorRepository visitorRepository;
@Autowired UserRepository userRepository;
@Autowired AuditLogRepository auditLogRepository;
@Test
void approvalIsAudited() {
User host = userRepository.findByUsername("host").orElseThrow();
User admin = userRepository.findByUsername("admin").orElseThrow();
Visitor v = new Visitor();
v.setName("감사테스트-" + System.nanoTime());
visitorRepository.save(v);
LocalDate today = LocalDate.now();
VisitRequest vr = new VisitRequest();
vr.setVisitor(v);
vr.setHost(host);
vr.setPurpose("audit");
vr.setVisitFrom(today.atStartOfDay());
vr.setVisitTo(today.atTime(23, 59));
vr.setStatus(VisitStatus.PENDING);
Long vrId = visitRequestRepository.save(vr).getId();
approvalService.approve(vrId, admin.getId(), "확인함");
boolean approveAudited = auditLogRepository.findTop200ByOrderByCreatedAtDesc().stream()
.anyMatch(a -> a.getAction() == AuditAction.APPROVE
&& "VISIT_REQUEST".equals(a.getTargetType())
&& vrId.equals(a.getTargetId()));
assertThat(approveAudited).as("APPROVE audit row for the request").isTrue();
}
@Test
void blacklistAddIsAudited() {
BlacklistRequest req = new BlacklistRequest();
req.setName("차단테스트-" + System.nanoTime());
req.setReason("테스트 차단");
User admin = userRepository.findByUsername("admin").orElseThrow();
var saved = blacklistService.add(req, admin.getId());
boolean added = auditLogRepository.findTop200ByOrderByCreatedAtDesc().stream()
.anyMatch(a -> a.getAction() == AuditAction.BLACKLIST_ADD
&& saved.getId().equals(a.getTargetId()));
assertThat(added).as("BLACKLIST_ADD audit row").isTrue();
}
}