feat: add audit log viewing for vendor and tenant personas
All checks were successful
CI / build (push) Successful in 52s
CI / docker (push) Successful in 40s

Vendor sees all audit events with tenant filter at /vendor/audit.
Tenant admin sees only their own events at /tenant/audit.
Both support pagination, action/result filters, and text search.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-10 13:07:18 +02:00
parent 1750fe64a2
commit 8b94937d38
13 changed files with 557 additions and 3 deletions

View File

@@ -0,0 +1,38 @@
package net.siegeln.cameleer.saas.audit;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
public final class AuditDto {
private AuditDto() {}
public record AuditLogEntry(
UUID id,
String actorEmail,
UUID tenantId,
String action,
String resource,
String environment,
String result,
String sourceIp,
Instant createdAt
) {
public static AuditLogEntry from(AuditEntity e) {
return new AuditLogEntry(
e.getId(), e.getActorEmail(), e.getTenantId(),
e.getAction(), e.getResource(), e.getEnvironment(),
e.getResult(), e.getSourceIp(), e.getCreatedAt()
);
}
}
public record AuditLogPage(
List<AuditLogEntry> content,
int page,
int size,
long totalElements,
int totalPages
) {}
}

View File

@@ -1,6 +1,10 @@
package net.siegeln.cameleer.saas.audit;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.Instant;
@@ -13,4 +17,26 @@ public interface AuditRepository extends JpaRepository<AuditEntity, UUID> {
List<AuditEntity> findByTenantIdAndCreatedAtBetween(UUID tenantId, Instant from, Instant to);
List<AuditEntity> findByActorId(UUID actorId);
@Query("""
SELECT a FROM AuditEntity a
WHERE (:tenantId IS NULL OR a.tenantId = :tenantId)
AND (:action IS NULL OR a.action = :action)
AND (:result IS NULL OR a.result = :result)
AND (:from IS NULL OR a.createdAt >= :from)
AND (:to IS NULL OR a.createdAt <= :to)
AND (:search IS NULL
OR LOWER(a.actorEmail) LIKE LOWER(CONCAT('%', :search, '%'))
OR LOWER(a.resource) LIKE LOWER(CONCAT('%', :search, '%')))
ORDER BY a.createdAt DESC
""")
Page<AuditEntity> findFiltered(
@Param("tenantId") UUID tenantId,
@Param("action") String action,
@Param("result") String result,
@Param("from") Instant from,
@Param("to") Instant to,
@Param("search") String search,
Pageable pageable
);
}

View File

@@ -1,7 +1,10 @@
package net.siegeln.cameleer.saas.audit;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.Map;
import java.util.UUID;
@@ -30,4 +33,10 @@ public class AuditService {
entry.setMetadata(metadata);
auditRepository.save(entry);
}
public Page<AuditEntity> search(UUID tenantId, String action, String result,
Instant from, Instant to, String search,
Pageable pageable) {
return auditRepository.findFiltered(tenantId, action, result, from, to, search, pageable);
}
}

View File

@@ -0,0 +1,50 @@
package net.siegeln.cameleer.saas.portal;
import net.siegeln.cameleer.saas.audit.AuditDto.AuditLogEntry;
import net.siegeln.cameleer.saas.audit.AuditDto.AuditLogPage;
import net.siegeln.cameleer.saas.audit.AuditService;
import net.siegeln.cameleer.saas.config.TenantContext;
import org.springframework.data.domain.PageRequest;
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.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.Instant;
import java.util.UUID;
@RestController
@RequestMapping("/api/tenant/audit")
public class TenantAuditController {
private final AuditService auditService;
public TenantAuditController(AuditService auditService) {
this.auditService = auditService;
}
@GetMapping
public ResponseEntity<AuditLogPage> list(
@RequestParam(required = false) String action,
@RequestParam(required = false) String result,
@RequestParam(required = false) String search,
@RequestParam(required = false) Instant from,
@RequestParam(required = false) Instant to,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "25") int size) {
UUID tenantId = TenantContext.getTenantId();
size = Math.min(size, 100);
var pageResult = auditService.search(tenantId, action, result, from, to, search,
PageRequest.of(page, size));
var entries = pageResult.getContent().stream()
.map(AuditLogEntry::from)
.toList();
return ResponseEntity.ok(new AuditLogPage(
entries, pageResult.getNumber(), pageResult.getSize(),
pageResult.getTotalElements(), pageResult.getTotalPages()));
}
}

View File

@@ -0,0 +1,51 @@
package net.siegeln.cameleer.saas.vendor;
import net.siegeln.cameleer.saas.audit.AuditDto.AuditLogEntry;
import net.siegeln.cameleer.saas.audit.AuditDto.AuditLogPage;
import net.siegeln.cameleer.saas.audit.AuditService;
import org.springframework.data.domain.PageRequest;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.Instant;
import java.util.UUID;
@RestController
@RequestMapping("/api/vendor/audit")
@PreAuthorize("hasAuthority('SCOPE_platform:admin')")
public class VendorAuditController {
private final AuditService auditService;
public VendorAuditController(AuditService auditService) {
this.auditService = auditService;
}
@GetMapping
public ResponseEntity<AuditLogPage> list(
@RequestParam(required = false) UUID tenantId,
@RequestParam(required = false) String action,
@RequestParam(required = false) String result,
@RequestParam(required = false) String search,
@RequestParam(required = false) Instant from,
@RequestParam(required = false) Instant to,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "25") int size) {
size = Math.min(size, 100);
var pageResult = auditService.search(tenantId, action, result, from, to, search,
PageRequest.of(page, size));
var entries = pageResult.getContent().stream()
.map(AuditLogEntry::from)
.toList();
return ResponseEntity.ok(new AuditLogPage(
entries, pageResult.getNumber(), pageResult.getSize(),
pageResult.getTotalElements(), pageResult.getTotalPages()));
}
}