feat(audit): add SOC 2 audit logging to vendor admin, auth policy, email connector, and certificate operations

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-29 11:23:13 +02:00
parent 295a185a03
commit 88733d76c0
7 changed files with 138 additions and 36 deletions

View File

@@ -102,15 +102,15 @@ public class CertificateController {
}
@PostMapping("/activate")
public ResponseEntity<Void> activate() {
certificateService.activate();
public ResponseEntity<Void> activate(@AuthenticationPrincipal Jwt jwt) {
certificateService.activate(resolveActorId(jwt));
return ResponseEntity.noContent().build();
}
@PostMapping("/restore")
public ResponseEntity<Void> restore() {
public ResponseEntity<Void> restore(@AuthenticationPrincipal Jwt jwt) {
try {
certificateService.restore();
certificateService.restore(resolveActorId(jwt));
return ResponseEntity.noContent().build();
} catch (IllegalStateException e) {
return ResponseEntity.badRequest().body(null);
@@ -118,8 +118,8 @@ public class CertificateController {
}
@DeleteMapping("/staged")
public ResponseEntity<Void> discardStaged() {
certificateService.discardStaged();
public ResponseEntity<Void> discardStaged(@AuthenticationPrincipal Jwt jwt) {
certificateService.discardStaged(resolveActorId(jwt));
return ResponseEntity.noContent().build();
}

View File

@@ -1,5 +1,7 @@
package io.cameleer.saas.certificate;
import io.cameleer.saas.audit.AuditAction;
import io.cameleer.saas.audit.AuditService;
import io.cameleer.saas.tenant.TenantRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -8,6 +10,7 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@Service
@@ -18,13 +21,16 @@ public class CertificateService {
private final CertificateManager certManager;
private final CertificateRepository certRepository;
private final TenantRepository tenantRepository;
private final AuditService auditService;
public CertificateService(CertificateManager certManager,
CertificateRepository certRepository,
TenantRepository tenantRepository) {
TenantRepository tenantRepository,
AuditService auditService) {
this.certManager = certManager;
this.certRepository = certRepository;
this.tenantRepository = tenantRepository;
this.auditService = auditService;
}
public record CertificateOverview(
@@ -61,12 +67,17 @@ public class CertificateService {
entity.setUploadedBy(actorId);
certRepository.save(entity);
auditService.log(actorId, null, null, AuditAction.CERTIFICATE_STAGED, entity.getFingerprint(),
null, null, "SUCCESS",
Map.of("subject", result.info().subject(), "issuer", result.info().issuer(),
"hasCa", result.info().hasCaBundle()));
log.info("Certificate staged by actor {}: subject={}", actorId, result.info().subject());
return result;
}
@Transactional
public void activate() {
public void activate(UUID actorId) {
var staged = certRepository.findByStatus(CertificateEntity.Status.STAGED)
.orElseThrow(() -> new IllegalStateException("No staged certificate to activate"));
@@ -86,11 +97,14 @@ public class CertificateService {
staged.setActivatedAt(Instant.now());
certRepository.save(staged);
auditService.log(actorId, null, null, AuditAction.CERTIFICATE_ACTIVATED, staged.getFingerprint(),
null, null, "SUCCESS", Map.of("subject", staged.getSubject()));
log.info("Certificate activated: subject={}", staged.getSubject());
}
@Transactional
public void restore() {
public void restore(UUID actorId) {
var archived = certRepository.findByStatus(CertificateEntity.Status.ARCHIVED)
.orElseThrow(() -> new IllegalStateException("No archived certificate to restore"));
@@ -115,13 +129,18 @@ public class CertificateService {
certRepository.save(active);
}
auditService.log(actorId, null, null, AuditAction.CERTIFICATE_RESTORED, archived.getFingerprint(),
null, null, "SUCCESS", Map.of("subject", archived.getSubject()));
log.info("Certificate restored from archive: subject={}", archived.getSubject());
}
@Transactional
public void discardStaged() {
public void discardStaged(UUID actorId) {
certManager.discardStaged();
certRepository.findByStatus(CertificateEntity.Status.STAGED).ifPresent(certRepository::delete);
auditService.log(actorId, null, null, AuditAction.CERTIFICATE_DISCARDED, "staged",
null, null, "SUCCESS", null);
log.info("Staged certificate discarded");
}

View File

@@ -7,6 +7,8 @@ import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
@@ -15,6 +17,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
import java.util.UUID;
@RestController
@RequestMapping("/api/vendor/email-connector")
@@ -76,7 +79,8 @@ public class EmailConnectorController {
}
@PostMapping
public ResponseEntity<?> save(@Valid @RequestBody SmtpConfigRequest request) {
public ResponseEntity<?> save(@AuthenticationPrincipal Jwt jwt,
@Valid @RequestBody SmtpConfigRequest request) {
// Resolve password: use provided value, or fall back to existing password from Logto
String password = request.password();
if (password == null || password.isBlank()) {
@@ -98,13 +102,13 @@ public class EmailConnectorController {
return ResponseEntity.badRequest().body(Map.of("message", e.getMessage()));
}
var status = emailConnectorService.saveSmtpConnector(smtp, request.registrationEnabled());
var status = emailConnectorService.saveSmtpConnector(smtp, request.registrationEnabled(), resolveActorId(jwt));
return ResponseEntity.ok(EmailConnectorResponse.from(status));
}
@DeleteMapping
public ResponseEntity<Void> delete() {
emailConnectorService.deleteEmailConnector();
public ResponseEntity<Void> delete(@AuthenticationPrincipal Jwt jwt) {
emailConnectorService.deleteEmailConnector(resolveActorId(jwt));
return ResponseEntity.noContent().build();
}
@@ -119,13 +123,22 @@ public class EmailConnectorController {
}
@PostMapping("/registration")
public ResponseEntity<Void> toggleRegistration(@RequestBody Map<String, Boolean> body) {
public ResponseEntity<Void> toggleRegistration(@AuthenticationPrincipal Jwt jwt,
@RequestBody Map<String, Boolean> body) {
boolean enabled = body.getOrDefault("enabled", false);
var existing = emailConnectorService.getEmailConnector();
if (existing == null && enabled) {
return ResponseEntity.badRequest().build();
}
emailConnectorService.setRegistrationEnabled(enabled);
emailConnectorService.setRegistrationEnabled(enabled, resolveActorId(jwt));
return ResponseEntity.noContent().build();
}
private UUID resolveActorId(Jwt jwt) {
try {
return UUID.fromString(jwt.getSubject());
} catch (Exception e) {
return UUID.nameUUIDFromBytes(jwt.getSubject().getBytes());
}
}
}

View File

@@ -1,5 +1,7 @@
package io.cameleer.saas.vendor;
import io.cameleer.saas.audit.AuditAction;
import io.cameleer.saas.audit.AuditService;
import io.cameleer.saas.identity.LogtoManagementClient;
import io.cameleer.saas.provisioning.ProvisioningProperties;
import org.slf4j.Logger;
@@ -14,6 +16,7 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.UUID;
@Service
public class EmailConnectorService {
@@ -23,10 +26,14 @@ public class EmailConnectorService {
private final LogtoManagementClient logtoClient;
private final ProvisioningProperties provisioningProps;
private final AuditService auditService;
public EmailConnectorService(LogtoManagementClient logtoClient, ProvisioningProperties provisioningProps) {
public EmailConnectorService(LogtoManagementClient logtoClient,
ProvisioningProperties provisioningProps,
AuditService auditService) {
this.logtoClient = logtoClient;
this.provisioningProps = provisioningProps;
this.auditService = auditService;
}
public record SmtpConfig(String host, int port, String username, String password, String fromEmail) {}
@@ -120,7 +127,7 @@ public class EmailConnectorService {
}
/** Create or update the SMTP email connector. Returns the connector status. */
public EmailConnectorStatus saveSmtpConnector(SmtpConfig smtp, Boolean registrationEnabled) {
public EmailConnectorStatus saveSmtpConnector(SmtpConfig smtp, Boolean registrationEnabled, UUID actorId) {
var connectorConfig = buildSmtpConfig(smtp);
// Check if an email connector already exists
@@ -133,20 +140,24 @@ public class EmailConnectorService {
log.info("Created SMTP email connector: {}", result != null ? result.get("id") : "unknown");
}
auditService.log(actorId, null, null, AuditAction.EMAIL_CONNECTOR_SAVED, null, null, null, "SUCCESS",
Map.of("host", smtp.host(), "port", smtp.port(), "fromEmail", smtp.fromEmail()));
// Handle registration toggle
boolean enableReg = registrationEnabled != null ? registrationEnabled : (existing == null);
setRegistrationEnabled(enableReg);
setRegistrationEnabled(enableReg, actorId);
return getEmailConnector();
}
/** Delete the email connector and disable registration. */
public void deleteEmailConnector() {
public void deleteEmailConnector(UUID actorId) {
var existing = getEmailConnector();
if (existing != null) {
logtoClient.deleteConnector(existing.connectorId());
setRegistrationEnabled(false);
log.info("Deleted email connector: {}", existing.connectorId());
auditService.log(actorId, null, null, AuditAction.EMAIL_CONNECTOR_DELETED, null, null, null, "SUCCESS", null);
setRegistrationEnabled(false, actorId);
}
}
@@ -169,7 +180,7 @@ public class EmailConnectorService {
}
/** Set registration mode on the Logto sign-in experience. */
public void setRegistrationEnabled(boolean enabled) {
public void setRegistrationEnabled(boolean enabled, UUID actorId) {
if (enabled) {
logtoClient.updateSignInExperience(Map.of(
"signInMode", "SignInAndRegister",
@@ -201,6 +212,8 @@ public class EmailConnectorService {
)
));
}
auditService.log(actorId, null, null, AuditAction.REGISTRATION_TOGGLED, null, null, null, "SUCCESS",
Map.of("enabled", enabled));
}
/** Check if registration is currently enabled in Logto. */

View File

@@ -10,6 +10,7 @@ import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@RestController
@RequestMapping("/api/vendor/admins")
@@ -27,27 +28,38 @@ public class VendorAdminController {
}
@PostMapping
public CreateAdminResponse createAdmin(@RequestBody CreateAdminRequest request) {
return vendorAdminService.createAdmin(request);
public CreateAdminResponse createAdmin(@AuthenticationPrincipal Jwt jwt,
@RequestBody CreateAdminRequest request) {
return vendorAdminService.createAdmin(request, resolveActorId(jwt));
}
@DeleteMapping("/{userId}")
public ResponseEntity<Void> removeAdmin(@AuthenticationPrincipal Jwt jwt,
@PathVariable String userId) {
vendorAdminService.removeAdmin(userId, jwt.getSubject());
vendorAdminService.removeAdmin(userId, jwt.getSubject(), resolveActorId(jwt));
return ResponseEntity.noContent().build();
}
@PostMapping("/{userId}/reset-password")
public ResponseEntity<Void> resetPassword(@PathVariable String userId,
public ResponseEntity<Void> resetPassword(@AuthenticationPrincipal Jwt jwt,
@PathVariable String userId,
@RequestBody Map<String, String> body) {
vendorAdminService.resetAdminPassword(userId, body.get("password"));
vendorAdminService.resetAdminPassword(userId, body.get("password"), resolveActorId(jwt));
return ResponseEntity.noContent().build();
}
@DeleteMapping("/{userId}/mfa")
public ResponseEntity<Void> resetMfa(@PathVariable String userId) {
vendorAdminService.resetAdminMfa(userId);
public ResponseEntity<Void> resetMfa(@AuthenticationPrincipal Jwt jwt,
@PathVariable String userId) {
vendorAdminService.resetAdminMfa(userId, resolveActorId(jwt));
return ResponseEntity.noContent().build();
}
private UUID resolveActorId(Jwt jwt) {
try {
return UUID.fromString(jwt.getSubject());
} catch (Exception e) {
return UUID.nameUUIDFromBytes(jwt.getSubject().getBytes());
}
}
}

View File

@@ -1,6 +1,8 @@
package io.cameleer.saas.vendor;
import io.cameleer.saas.account.AccountService;
import io.cameleer.saas.audit.AuditAction;
import io.cameleer.saas.audit.AuditService;
import io.cameleer.saas.identity.LogtoManagementClient;
import io.cameleer.saas.notification.PasswordResetNotificationService;
import org.slf4j.Logger;
@@ -11,6 +13,7 @@ import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@Service
public class VendorAdminService {
@@ -22,15 +25,18 @@ public class VendorAdminService {
private final AccountService accountService;
private final EmailConnectorService emailConnectorService;
private final PasswordResetNotificationService passwordNotificationService;
private final AuditService auditService;
public VendorAdminService(LogtoManagementClient logtoClient,
AccountService accountService,
EmailConnectorService emailConnectorService,
PasswordResetNotificationService passwordNotificationService) {
PasswordResetNotificationService passwordNotificationService,
AuditService auditService) {
this.logtoClient = logtoClient;
this.accountService = accountService;
this.emailConnectorService = emailConnectorService;
this.passwordNotificationService = passwordNotificationService;
this.auditService = auditService;
}
// --- Records ---
@@ -62,7 +68,7 @@ public class VendorAdminService {
.toList();
}
public CreateAdminResponse createAdmin(CreateAdminRequest request) {
public CreateAdminResponse createAdmin(CreateAdminRequest request, UUID actorId) {
if (request.email() == null || request.email().isBlank() || !request.email().contains("@")) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Valid email address required");
}
@@ -97,24 +103,28 @@ public class VendorAdminService {
logtoClient.assignGlobalRole(userId, roleId);
log.info("Assigned vendor role to user {}", userId);
auditService.log(actorId, null, null, AuditAction.ADMIN_CREATED, userId, null, null, "SUCCESS",
Map.of("email", request.email(), "invited", invited));
return new CreateAdminResponse(invited, invited ? null : tempPassword);
}
public void removeAdmin(String userId, String requesterId) {
public void removeAdmin(String userId, String requesterId, UUID actorId) {
if (userId.equals(requesterId)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Cannot remove yourself as administrator");
}
String roleId = getVendorRoleId();
logtoClient.revokeGlobalRole(userId, roleId);
log.info("Revoked vendor role from user {}", userId);
auditService.log(actorId, null, null, AuditAction.ADMIN_REMOVED, userId, null, null, "SUCCESS", null);
}
public void resetAdminPassword(String userId, String newPassword) {
public void resetAdminPassword(String userId, String newPassword, UUID actorId) {
verifyIsVendorAdmin(userId);
accountService.validatePassword(newPassword);
logtoClient.updateUserPassword(userId, newPassword);
log.info("Reset password for vendor admin {}", userId);
auditService.log(actorId, null, null, AuditAction.ADMIN_PASSWORD_RESET, userId, null, null, "SUCCESS", null);
// Send notification email
try {
@@ -130,10 +140,11 @@ public class VendorAdminService {
}
}
public void resetAdminMfa(String userId) {
public void resetAdminMfa(String userId, UUID actorId) {
verifyIsVendorAdmin(userId);
logtoClient.deleteAllMfaVerifications(userId);
log.info("Reset MFA for vendor admin {}", userId);
auditService.log(actorId, null, null, AuditAction.ADMIN_MFA_RESET, userId, null, null, "SUCCESS", null);
}
private void verifyIsVendorAdmin(String userId) {

View File

@@ -1,10 +1,17 @@
package io.cameleer.saas.vendor;
import io.cameleer.saas.audit.AuditAction;
import io.cameleer.saas.audit.AuditService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
@RestController
@RequestMapping("/api/vendor/auth-policy")
@@ -15,9 +22,12 @@ public class VendorAuthPolicyController {
private static final Set<String> VALID_PASSKEY_MODES = Set.of("optional", "preferred", "required");
private final VendorAuthPolicyRepository repository;
private final AuditService auditService;
public VendorAuthPolicyController(VendorAuthPolicyRepository repository) {
public VendorAuthPolicyController(VendorAuthPolicyRepository repository,
AuditService auditService) {
this.repository = repository;
this.auditService = auditService;
}
public record AuthPolicyResponse(String mfaMode, boolean passkeyEnabled, String passkeyMode) {
@@ -34,9 +44,14 @@ public class VendorAuthPolicyController {
}
@PutMapping
public ResponseEntity<AuthPolicyResponse> updatePolicy(@RequestBody AuthPolicyUpdateRequest request) {
public ResponseEntity<AuthPolicyResponse> updatePolicy(@AuthenticationPrincipal Jwt jwt,
@RequestBody AuthPolicyUpdateRequest request) {
var policy = repository.getPolicy();
String mfaMode_old = policy.getMfaMode();
boolean passkeyEnabled_old = policy.isPasskeyEnabled();
String passkeyMode_old = policy.getPasskeyMode();
if (request.mfaMode() != null) {
if (!VALID_MFA_MODES.contains(request.mfaMode())) {
return ResponseEntity.badRequest().build();
@@ -54,6 +69,25 @@ public class VendorAuthPolicyController {
}
repository.save(policy);
var changes = new HashMap<String, Object>();
changes.put("mfaMode_old", mfaMode_old);
changes.put("mfaMode_new", policy.getMfaMode());
changes.put("passkeyEnabled_old", passkeyEnabled_old);
changes.put("passkeyEnabled_new", policy.isPasskeyEnabled());
changes.put("passkeyMode_old", passkeyMode_old);
changes.put("passkeyMode_new", policy.getPasskeyMode());
auditService.log(resolveActorId(jwt), null, null, AuditAction.PLATFORM_AUTH_POLICY_UPDATED,
null, null, null, "SUCCESS", changes);
return ResponseEntity.ok(AuthPolicyResponse.from(policy));
}
private UUID resolveActorId(Jwt jwt) {
try {
return UUID.fromString(jwt.getSubject());
} catch (Exception e) {
return UUID.nameUUIDFromBytes(jwt.getSubject().getBytes());
}
}
}