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;
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.Pageable;
import org.springframework.stereotype.Service;
@@ -7,20 +10,29 @@ import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
@Service
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.logtoClient = logtoClient;
}
public void log(UUID actorId, String actorEmail, UUID tenantId,
AuditAction action, String resource,
String environment, String sourceIp,
String result, Map<String, Object> metadata) {
if (actorEmail == null && actorId != null) {
actorEmail = resolveActorName(actorId.toString());
}
var entry = new AuditEntity();
entry.setActorId(actorId);
entry.setActorEmail(actorEmail);
@@ -39,4 +51,23 @@ public class AuditService {
Pageable 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();
}
/** 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 synchronized String getAccessToken() {

View File

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