fix: resolve actor name from Logto for audit log entries
All checks were successful
CI / build (push) Successful in 50s
CI / docker (push) Successful in 32s

AuditService now looks up username/name/email from Logto Management API
when actorEmail is null, with an in-memory cache to avoid repeated calls.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-10 17:47:43 +02:00
parent 0a1e848ef7
commit 2607ef5dbe
3 changed files with 54 additions and 3 deletions

View File

@@ -1,5 +1,8 @@
package net.siegeln.cameleer.saas.audit; package net.siegeln.cameleer.saas.audit;
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -7,20 +10,29 @@ import org.springframework.stereotype.Service;
import java.time.Instant; import java.time.Instant;
import java.util.Map; import java.util.Map;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
@Service @Service
public class AuditService { public class AuditService {
private final AuditRepository auditRepository; private static final Logger log = LoggerFactory.getLogger(AuditService.class);
public AuditService(AuditRepository auditRepository) { private final AuditRepository auditRepository;
private final LogtoManagementClient logtoClient;
private final ConcurrentHashMap<String, String> userNameCache = new ConcurrentHashMap<>();
public AuditService(AuditRepository auditRepository, LogtoManagementClient logtoClient) {
this.auditRepository = auditRepository; this.auditRepository = auditRepository;
this.logtoClient = logtoClient;
} }
public void log(UUID actorId, String actorEmail, UUID tenantId, public void log(UUID actorId, String actorEmail, UUID tenantId,
AuditAction action, String resource, AuditAction action, String resource,
String environment, String sourceIp, String environment, String sourceIp,
String result, Map<String, Object> metadata) { String result, Map<String, Object> metadata) {
if (actorEmail == null && actorId != null) {
actorEmail = resolveActorName(actorId.toString());
}
var entry = new AuditEntity(); var entry = new AuditEntity();
entry.setActorId(actorId); entry.setActorId(actorId);
entry.setActorEmail(actorEmail); entry.setActorEmail(actorEmail);
@@ -39,4 +51,23 @@ public class AuditService {
Pageable pageable) { Pageable pageable) {
return auditRepository.findFiltered(tenantId, action, result, from, to, search, pageable); return auditRepository.findFiltered(tenantId, action, result, from, to, search, pageable);
} }
private String resolveActorName(String userId) {
return userNameCache.computeIfAbsent(userId, id -> {
try {
var user = logtoClient.getUser(id);
if (user == null) return id;
var username = user.get("username");
if (username != null && !username.toString().isBlank()) return username.toString();
var name = user.get("name");
if (name != null && !name.toString().isBlank()) return name.toString();
var email = user.get("primaryEmail");
if (email != null && !email.toString().isBlank()) return email.toString();
return id;
} catch (Exception e) {
log.warn("Failed to resolve actor name for {}: {}", id, e.getMessage());
return id;
}
});
}
} }

View File

@@ -398,6 +398,22 @@ public class LogtoManagementClient {
.toBodilessEntity(); .toBodilessEntity();
} }
/** Get a user by ID. Returns username, primaryEmail, name. */
@SuppressWarnings("unchecked")
public Map<String, Object> getUser(String userId) {
if (!isAvailable() || userId == null) return null;
try {
return (Map<String, Object>) restClient.get()
.uri(config.getLogtoEndpoint() + "/api/users/" + userId)
.header("Authorization", "Bearer " + getAccessToken())
.retrieve()
.body(Map.class);
} catch (Exception e) {
log.warn("Failed to get user {}: {}", userId, e.getMessage());
return null;
}
}
private static final String MGMT_API_RESOURCE = "https://default.logto.app/api"; private static final String MGMT_API_RESOURCE = "https://default.logto.app/api";
private synchronized String getAccessToken() { private synchronized String getAccessToken() {

View File

@@ -1,5 +1,6 @@
package net.siegeln.cameleer.saas.audit; package net.siegeln.cameleer.saas.audit;
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
@@ -20,11 +21,14 @@ class AuditServiceTest {
@Mock @Mock
private AuditRepository auditRepository; private AuditRepository auditRepository;
@Mock
private LogtoManagementClient logtoClient;
private AuditService auditService; private AuditService auditService;
@BeforeEach @BeforeEach
void setUp() { void setUp() {
auditService = new AuditService(auditRepository); auditService = new AuditService(auditRepository, logtoClient);
} }
@Test @Test