diff --git a/backend/src/main/java/com/itcenter/acs/controller/AuditController.java b/backend/src/main/java/com/itcenter/acs/controller/AuditController.java new file mode 100644 index 0000000..7bec80c --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/controller/AuditController.java @@ -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>> recent() { + List items = auditService.recent().stream() + .map(AuditLogResponse::from) + .toList(); + return ResponseEntity.ok(ApiResponse.success(items)); + } +} diff --git a/backend/src/main/java/com/itcenter/acs/dto/AuditLogResponse.java b/backend/src/main/java/com/itcenter/acs/dto/AuditLogResponse.java new file mode 100644 index 0000000..de41667 --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/dto/AuditLogResponse.java @@ -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()); + } +} diff --git a/backend/src/main/java/com/itcenter/acs/entity/AuditAction.java b/backend/src/main/java/com/itcenter/acs/entity/AuditAction.java new file mode 100644 index 0000000..cc3322c --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/entity/AuditAction.java @@ -0,0 +1,9 @@ +package com.itcenter.acs.entity; + +/** Auditable administrative actions. */ +public enum AuditAction { + APPROVE, + REJECT, + BLACKLIST_ADD, + BLACKLIST_REMOVE +} diff --git a/backend/src/main/java/com/itcenter/acs/entity/AuditLog.java b/backend/src/main/java/com/itcenter/acs/entity/AuditLog.java new file mode 100644 index 0000000..7320ccf --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/entity/AuditLog.java @@ -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; +} diff --git a/backend/src/main/java/com/itcenter/acs/repository/AuditLogRepository.java b/backend/src/main/java/com/itcenter/acs/repository/AuditLogRepository.java new file mode 100644 index 0000000..8845589 --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/repository/AuditLogRepository.java @@ -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 { + List findTop200ByOrderByCreatedAtDesc(); +} diff --git a/backend/src/main/java/com/itcenter/acs/service/ApprovalService.java b/backend/src/main/java/com/itcenter/acs/service/ApprovalService.java index ac96754..abc79e7 100644 --- a/backend/src/main/java/com/itcenter/acs/service/ApprovalService.java +++ b/backend/src/main/java/com/itcenter/acs/service/ApprovalService.java @@ -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); } diff --git a/backend/src/main/java/com/itcenter/acs/service/AuditService.java b/backend/src/main/java/com/itcenter/acs/service/AuditService.java new file mode 100644 index 0000000..02169c0 --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/service/AuditService.java @@ -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 recent() { + return auditLogRepository.findTop200ByOrderByCreatedAtDesc(); + } + + private static String truncate(String s) { + if (s == null) { + return null; + } + return s.length() <= 500 ? s : s.substring(0, 500); + } +} diff --git a/backend/src/main/java/com/itcenter/acs/service/BlacklistService.java b/backend/src/main/java/com/itcenter/acs/service/BlacklistService.java index e9eb9c7..b28296e 100644 --- a/backend/src/main/java/com/itcenter/acs/service/BlacklistService.java +++ b/backend/src/main/java/com/itcenter/acs/service/BlacklistService.java @@ -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 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. */ diff --git a/backend/src/main/resources/db/migration/V2__audit_log.sql b/backend/src/main/resources/db/migration/V2__audit_log.sql new file mode 100644 index 0000000..9de12eb --- /dev/null +++ b/backend/src/main/resources/db/migration/V2__audit_log.sql @@ -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); diff --git a/backend/src/test/java/com/itcenter/acs/AuditLogTest.java b/backend/src/test/java/com/itcenter/acs/AuditLogTest.java new file mode 100644 index 0000000..16ba243 --- /dev/null +++ b/backend/src/test/java/com/itcenter/acs/AuditLogTest.java @@ -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(); + } +}