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:
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.itcenter.acs.entity;
|
||||
|
||||
/** Auditable administrative actions. */
|
||||
public enum AuditAction {
|
||||
APPROVE,
|
||||
REJECT,
|
||||
BLACKLIST_ADD,
|
||||
BLACKLIST_REMOVE
|
||||
}
|
||||
46
backend/src/main/java/com/itcenter/acs/entity/AuditLog.java
Normal file
46
backend/src/main/java/com/itcenter/acs/entity/AuditLog.java
Normal 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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
16
backend/src/main/resources/db/migration/V2__audit_log.sql
Normal file
16
backend/src/main/resources/db/migration/V2__audit_log.sql
Normal 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);
|
||||
76
backend/src/test/java/com/itcenter/acs/AuditLogTest.java
Normal file
76
backend/src/test/java/com/itcenter/acs/AuditLogTest.java
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user