36 KiB
SOC 2 Audit Logging Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add comprehensive audit logging to all SOC 2-relevant operations across vendor admin, tenant admin, account security, certificate management, SSO, and email connector services.
Architecture: Wire AuditService.log() calls into every security-relevant service method that currently only has SLF4J logging or no logging at all. Follow the existing pattern: inject AuditService, pass actorId from controller via method parameter, call auditService.log() after the operation succeeds. Add a Flyway migration to protect the audit_log table from tampering.
Tech Stack: Java 21, Spring Boot 3, JPA/Hibernate, Flyway, PostgreSQL
File Map
| File | Action | Responsibility |
|---|---|---|
src/main/java/io/cameleer/saas/audit/AuditAction.java |
Modify | Add ~30 new enum values |
src/main/java/io/cameleer/saas/vendor/VendorAdminService.java |
Modify | Audit 4 admin lifecycle methods |
src/main/java/io/cameleer/saas/vendor/VendorAdminController.java |
Modify | Pass actorId to service methods |
src/main/java/io/cameleer/saas/vendor/VendorAuthPolicyController.java |
Modify | Audit policy update inline |
src/main/java/io/cameleer/saas/vendor/EmailConnectorController.java |
Modify | Pass actorId to service methods |
src/main/java/io/cameleer/saas/vendor/EmailConnectorService.java |
Modify | Audit 3 connector operations |
src/main/java/io/cameleer/saas/certificate/CertificateService.java |
Modify | Audit 4 cert lifecycle methods |
src/main/java/io/cameleer/saas/certificate/CertificateController.java |
Modify | Pass actorId to activate/restore/discard |
src/main/java/io/cameleer/saas/certificate/TenantCaCertService.java |
Modify | Audit 3 CA cert operations |
src/main/java/io/cameleer/saas/portal/TenantPortalController.java |
Modify | Pass actorId to team/server ops |
src/main/java/io/cameleer/saas/portal/TenantPortalService.java |
Modify | Audit 10 team/server/settings methods |
src/main/java/io/cameleer/saas/account/AccountService.java |
Modify | Audit 8 account security methods |
src/main/java/io/cameleer/saas/portal/TenantSsoService.java |
Modify | Audit 3 SSO connector operations |
src/main/java/io/cameleer/saas/portal/TenantSsoController.java |
Modify | Pass actorId to service methods |
src/main/java/io/cameleer/saas/vendor/VendorTenantService.java |
Modify | Audit restartServer, upgradeServer |
src/main/java/io/cameleer/saas/vendor/VendorTenantController.java |
Modify | Pass actorId to restart/upgrade |
src/main/resources/db/migration/V004__audit_log_immutability.sql |
Create | Prevent UPDATE/DELETE on audit_log |
Task 1: Extend AuditAction Enum
Files:
-
Modify:
src/main/java/io/cameleer/saas/audit/AuditAction.java -
Step 1: Add all new enum values
Replace the entire AuditAction enum with:
package io.cameleer.saas.audit;
public enum AuditAction {
// Authentication
AUTH_REGISTER, AUTH_LOGIN, AUTH_LOGIN_FAILED, AUTH_LOGOUT,
// Tenant lifecycle
TENANT_CREATE, TENANT_UPDATE, TENANT_SUSPEND, TENANT_REACTIVATE, TENANT_DELETE,
TENANT_AUTH_SETTINGS_UPDATED,
// Environments & apps (future phases)
ENVIRONMENT_CREATE, ENVIRONMENT_UPDATE, ENVIRONMENT_DELETE,
APP_CREATE, APP_DEPLOY, APP_PROMOTE, APP_ROLLBACK, APP_SCALE, APP_STOP, APP_DELETE,
// Secrets (future phases)
SECRET_CREATE, SECRET_READ, SECRET_UPDATE, SECRET_DELETE, SECRET_ROTATE,
// Config
CONFIG_UPDATE,
// Team management (existing keys kept for compat, new ones added)
TEAM_INVITE, TEAM_REMOVE, TEAM_ROLE_CHANGE,
TEAM_MEMBER_PASSWORD_RESET, TEAM_MEMBER_MFA_RESET,
// License
LICENSE_GENERATE, LICENSE_REVOKE,
// Vendor admin lifecycle
ADMIN_CREATED, ADMIN_REMOVED, ADMIN_PASSWORD_RESET, ADMIN_MFA_RESET,
// Platform auth policy
PLATFORM_AUTH_POLICY_UPDATED,
// Email connector
EMAIL_CONNECTOR_SAVED, EMAIL_CONNECTOR_DELETED, REGISTRATION_TOGGLED,
// Platform certificate management
CERTIFICATE_STAGED, CERTIFICATE_ACTIVATED, CERTIFICATE_RESTORED, CERTIFICATE_DISCARDED,
// Tenant CA certificate management
TENANT_CA_CERT_STAGED, TENANT_CA_CERT_ACTIVATED, TENANT_CA_CERT_DELETED,
// Account security
PROFILE_UPDATED, PASSWORD_CHANGED,
MFA_TOTP_ENABLED, MFA_TOTP_REMOVED,
MFA_BACKUP_CODES_GENERATED,
PASSKEY_RENAMED, PASSKEY_DELETED,
MFA_PREFERENCE_CHANGED,
// SSO connectors
SSO_CONNECTOR_CREATED, SSO_CONNECTOR_UPDATED, SSO_CONNECTOR_DELETED,
// Server operations
SERVER_RESTARTED, SERVER_UPGRADED, SERVER_ADMIN_PASSWORD_RESET
}
- Step 2: Build to verify compilation
Run: ./mvnw compile -q
Expected: BUILD SUCCESS
- Step 3: Commit
git add src/main/java/io/cameleer/saas/audit/AuditAction.java
git commit -m "feat(audit): extend AuditAction enum with SOC 2 action types"
Task 2: VendorAdminService — Audit Admin Lifecycle
Files:
-
Modify:
src/main/java/io/cameleer/saas/vendor/VendorAdminService.java -
Modify:
src/main/java/io/cameleer/saas/vendor/VendorAdminController.java -
Step 1: Inject AuditService into VendorAdminService
Add to imports:
import io.cameleer.saas.audit.AuditAction;
import io.cameleer.saas.audit.AuditService;
import java.util.UUID;
Add field and constructor parameter:
private final AuditService auditService;
Update constructor to accept AuditService auditService and assign this.auditService = auditService;.
- Step 2: Add actorId parameter and audit logging to createAdmin
Change signature from createAdmin(CreateAdminRequest request) to createAdmin(CreateAdminRequest request, UUID actorId).
After logtoClient.assignGlobalRole(userId, roleId); (line 98), add:
auditService.log(actorId, null, null,
AuditAction.ADMIN_CREATED, userId,
null, null, "SUCCESS",
Map.of("email", request.email(), "invited", invited));
- Step 3: Add actorId parameter and audit logging to removeAdmin
Change signature from removeAdmin(String userId, String requesterId) to removeAdmin(String userId, String requesterId, UUID actorId).
After logtoClient.revokeGlobalRole(userId, roleId); (line 109), add:
auditService.log(actorId, null, null,
AuditAction.ADMIN_REMOVED, userId,
null, null, "SUCCESS", null);
- Step 4: Add actorId parameter and audit logging to resetAdminPassword
Change signature from resetAdminPassword(String userId, String newPassword) to resetAdminPassword(String userId, String newPassword, UUID actorId).
After logtoClient.updateUserPassword(userId, newPassword); (line 116), add:
auditService.log(actorId, null, null,
AuditAction.ADMIN_PASSWORD_RESET, userId,
null, null, "SUCCESS", null);
- Step 5: Add actorId parameter and audit logging to resetAdminMfa
Change signature from resetAdminMfa(String userId) to resetAdminMfa(String userId, UUID actorId).
After logtoClient.deleteAllMfaVerifications(userId); (line 135), add:
auditService.log(actorId, null, null,
AuditAction.ADMIN_MFA_RESET, userId,
null, null, "SUCCESS", null);
- Step 6: Update VendorAdminController to pass actorId
Add @AuthenticationPrincipal Jwt jwt to createAdmin method and pass resolveActorId(jwt) to service. Do the same for removeAdmin, resetPassword, resetMfa.
Add the resolveActorId helper (same pattern as VendorTenantController):
private UUID resolveActorId(Jwt jwt) {
try {
return UUID.fromString(jwt.getSubject());
} catch (Exception e) {
return UUID.nameUUIDFromBytes(jwt.getSubject().getBytes());
}
}
Updated controller methods:
@PostMapping
public CreateAdminResponse createAdmin(@RequestBody CreateAdminRequest request,
@AuthenticationPrincipal Jwt jwt) {
return vendorAdminService.createAdmin(request, resolveActorId(jwt));
}
@DeleteMapping("/{userId}")
public ResponseEntity<Void> removeAdmin(@AuthenticationPrincipal Jwt jwt,
@PathVariable String userId) {
UUID actorId = resolveActorId(jwt);
vendorAdminService.removeAdmin(userId, jwt.getSubject(), actorId);
return ResponseEntity.noContent().build();
}
@PostMapping("/{userId}/reset-password")
public ResponseEntity<Void> resetPassword(@AuthenticationPrincipal Jwt jwt,
@PathVariable String userId,
@RequestBody Map<String, String> body) {
vendorAdminService.resetAdminPassword(userId, body.get("password"), resolveActorId(jwt));
return ResponseEntity.noContent().build();
}
@DeleteMapping("/{userId}/mfa")
public ResponseEntity<Void> resetMfa(@AuthenticationPrincipal Jwt jwt,
@PathVariable String userId) {
vendorAdminService.resetAdminMfa(userId, resolveActorId(jwt));
return ResponseEntity.noContent().build();
}
- Step 7: Build and commit
Run: ./mvnw compile -q
git add src/main/java/io/cameleer/saas/vendor/VendorAdminService.java \
src/main/java/io/cameleer/saas/vendor/VendorAdminController.java
git commit -m "feat(audit): add audit logging to vendor admin lifecycle operations"
Task 3: VendorAuthPolicyController — Audit Policy Changes
Files:
-
Modify:
src/main/java/io/cameleer/saas/vendor/VendorAuthPolicyController.java -
Step 1: Inject AuditService and add audit logging to updatePolicy
Add imports:
import io.cameleer.saas.audit.AuditAction;
import io.cameleer.saas.audit.AuditService;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
Add AuditService auditService to constructor injection.
Update updatePolicy to accept @AuthenticationPrincipal Jwt jwt and log changes:
@PutMapping
public ResponseEntity<AuthPolicyResponse> updatePolicy(@RequestBody AuthPolicyUpdateRequest request,
@AuthenticationPrincipal Jwt jwt) {
var policy = repository.getPolicy();
Map<String, Object> changes = new HashMap<>();
if (request.mfaMode() != null) {
if (!VALID_MFA_MODES.contains(request.mfaMode())) {
return ResponseEntity.badRequest().build();
}
changes.put("mfaMode_old", policy.getMfaMode());
policy.setMfaMode(request.mfaMode());
changes.put("mfaMode_new", request.mfaMode());
}
if (request.passkeyEnabled() != null) {
changes.put("passkeyEnabled_old", policy.isPasskeyEnabled());
policy.setPasskeyEnabled(request.passkeyEnabled());
changes.put("passkeyEnabled_new", request.passkeyEnabled());
}
if (request.passkeyMode() != null) {
if (!VALID_PASSKEY_MODES.contains(request.passkeyMode())) {
return ResponseEntity.badRequest().build();
}
changes.put("passkeyMode_old", policy.getPasskeyMode());
policy.setPasskeyMode(request.passkeyMode());
changes.put("passkeyMode_new", request.passkeyMode());
}
repository.save(policy);
UUID actorId = resolveActorId(jwt);
auditService.log(actorId, null, null,
AuditAction.PLATFORM_AUTH_POLICY_UPDATED, "vendor_auth_policy",
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());
}
}
- Step 2: Build and commit
Run: ./mvnw compile -q
git add src/main/java/io/cameleer/saas/vendor/VendorAuthPolicyController.java
git commit -m "feat(audit): add audit logging to platform auth policy changes"
Task 4: EmailConnectorService — Audit Connector Operations
Files:
-
Modify:
src/main/java/io/cameleer/saas/vendor/EmailConnectorService.java -
Modify:
src/main/java/io/cameleer/saas/vendor/EmailConnectorController.java -
Step 1: Inject AuditService into EmailConnectorService
Add imports:
import io.cameleer.saas.audit.AuditAction;
import io.cameleer.saas.audit.AuditService;
import java.util.UUID;
Add AuditService auditService field and constructor parameter.
- Step 2: Add actorId + audit logging to saveSmtpConnector
Change signature to saveSmtpConnector(SmtpConfig smtp, Boolean registrationEnabled, UUID actorId).
After the save/create block (before return getEmailConnector()), add:
auditService.log(actorId, null, null,
AuditAction.EMAIL_CONNECTOR_SAVED, "email-connector",
null, null, "SUCCESS",
Map.of("host", smtp.host(), "port", smtp.port(), "fromEmail", smtp.fromEmail()));
- Step 3: Add actorId + audit logging to deleteEmailConnector
Change signature to deleteEmailConnector(UUID actorId).
After logtoClient.deleteConnector(existing.connectorId()); add:
auditService.log(actorId, null, null,
AuditAction.EMAIL_CONNECTOR_DELETED, "email-connector",
null, null, "SUCCESS", null);
- Step 4: Add actorId + audit logging to setRegistrationEnabled
Change signature to setRegistrationEnabled(boolean enabled, UUID actorId).
At the end of the method, add:
auditService.log(actorId, null, null,
AuditAction.REGISTRATION_TOGGLED, "registration",
null, null, "SUCCESS",
Map.of("enabled", enabled));
Note: The internal call from saveSmtpConnector also calls setRegistrationEnabled — update that call to pass actorId too: setRegistrationEnabled(enableReg, actorId);
Also update deleteEmailConnector internal call: setRegistrationEnabled(false, actorId);
- Step 5: Update EmailConnectorController
Add @AuthenticationPrincipal Jwt jwt to save, delete, and toggleRegistration methods.
Add the resolveActorId helper.
Pass resolveActorId(jwt) to service calls:
// In save():
var status = emailConnectorService.saveSmtpConnector(smtp, request.registrationEnabled(), resolveActorId(jwt));
// In delete():
emailConnectorService.deleteEmailConnector(resolveActorId(jwt));
// In toggleRegistration():
emailConnectorService.setRegistrationEnabled(enabled, resolveActorId(jwt));
- Step 6: Build and commit
Run: ./mvnw compile -q
git add src/main/java/io/cameleer/saas/vendor/EmailConnectorService.java \
src/main/java/io/cameleer/saas/vendor/EmailConnectorController.java
git commit -m "feat(audit): add audit logging to email connector operations"
Task 5: CertificateService — Audit Certificate Lifecycle
Files:
-
Modify:
src/main/java/io/cameleer/saas/certificate/CertificateService.java -
Modify:
src/main/java/io/cameleer/saas/certificate/CertificateController.java -
Step 1: Inject AuditService into CertificateService
Add imports:
import io.cameleer.saas.audit.AuditAction;
import io.cameleer.saas.audit.AuditService;
import java.util.Map;
Add AuditService auditService field and constructor parameter.
- Step 2: Add audit logging to stage method
The stage method already receives actorId. After certRepository.save(entity); and the existing log.info(...), add:
auditService.log(actorId, null, null,
AuditAction.CERTIFICATE_STAGED, entity.getFingerprint(),
null, null, "SUCCESS",
Map.of("subject", result.info().subject(),
"issuer", result.info().issuer(),
"hasCa", entity.isHasCa()));
- Step 3: Add actorId parameter and audit logging to activate
Change signature from activate() to activate(UUID actorId).
After certRepository.save(staged); and log.info(...), add:
auditService.log(actorId, null, null,
AuditAction.CERTIFICATE_ACTIVATED, staged.getFingerprint(),
null, null, "SUCCESS",
Map.of("subject", staged.getSubject()));
- Step 4: Add actorId parameter and audit logging to restore
Change signature from restore() to restore(UUID actorId).
After log.info(...), add:
auditService.log(actorId, null, null,
AuditAction.CERTIFICATE_RESTORED, archived.getFingerprint(),
null, null, "SUCCESS",
Map.of("subject", archived.getSubject()));
- Step 5: Add actorId parameter and audit logging to discardStaged
Change signature from discardStaged() to discardStaged(UUID actorId).
After log.info(...), add:
auditService.log(actorId, null, null,
AuditAction.CERTIFICATE_DISCARDED, "staged",
null, null, "SUCCESS", null);
- Step 6: Update CertificateController
Add @AuthenticationPrincipal Jwt jwt to activate, restore, and discardStaged methods.
Pass resolveActorId(jwt) to service calls:
// activate:
certificateService.activate(resolveActorId(jwt));
// restore:
certificateService.restore(resolveActorId(jwt));
// discardStaged:
certificateService.discardStaged(resolveActorId(jwt));
- Step 7: Build and commit
Run: ./mvnw compile -q
git add src/main/java/io/cameleer/saas/certificate/CertificateService.java \
src/main/java/io/cameleer/saas/certificate/CertificateController.java
git commit -m "feat(audit): add audit logging to platform certificate lifecycle"
Task 6: TenantCaCertService — Audit Tenant CA Operations
Files:
-
Modify:
src/main/java/io/cameleer/saas/certificate/TenantCaCertService.java -
Modify:
src/main/java/io/cameleer/saas/portal/TenantPortalController.java(CA endpoints only) -
Step 1: Inject AuditService into TenantCaCertService
Add imports:
import io.cameleer.saas.audit.AuditAction;
import io.cameleer.saas.audit.AuditService;
import java.util.Map;
Add AuditService auditService field and constructor parameter.
- Step 2: Add actorId + audit logging to stage
Change signature from stage(UUID tenantId, String label, byte[] certPem) to stage(UUID tenantId, String label, byte[] certPem, UUID actorId).
After caCertRepository.save(entity) and log.info(...), add:
auditService.log(actorId, null, tenantId,
AuditAction.TENANT_CA_CERT_STAGED, saved.getId().toString(),
null, null, "SUCCESS",
Map.of("subject", entity.getSubject(), "fingerprint", fingerprint));
- Step 3: Add actorId + audit logging to activate
Change signature from activate(UUID tenantId, UUID certId) to activate(UUID tenantId, UUID certId, UUID actorId).
After log.info(...), add:
auditService.log(actorId, null, tenantId,
AuditAction.TENANT_CA_CERT_ACTIVATED, certId.toString(),
null, null, "SUCCESS",
Map.of("subject", entity.getSubject()));
- Step 4: Add actorId + audit logging to delete
Change signature from delete(UUID tenantId, UUID certId) to delete(UUID tenantId, UUID certId, UUID actorId).
After log.info(...), add:
auditService.log(actorId, null, tenantId,
AuditAction.TENANT_CA_CERT_DELETED, certId.toString(),
null, null, "SUCCESS",
Map.of("wasActive", wasActive));
- Step 5: Update TenantPortalController CA endpoints
Add @AuthenticationPrincipal Jwt jwt to stageCaCert, activateCaCert, deleteCaCert.
Pass resolveActorId(jwt) to service calls. Add the resolveActorId helper to TenantPortalController:
private UUID resolveActorId(Jwt jwt) {
try {
return UUID.fromString(jwt.getSubject());
} catch (Exception e) {
return UUID.nameUUIDFromBytes(jwt.getSubject().getBytes());
}
}
Update calls:
var entity = caCertService.stage(tenantId, label, certFile.getBytes(), resolveActorId(jwt));
var entity = caCertService.activate(tenantId, id, resolveActorId(jwt));
caCertService.delete(tenantId, id, resolveActorId(jwt));
- Step 6: Build and commit
Run: ./mvnw compile -q
git add src/main/java/io/cameleer/saas/certificate/TenantCaCertService.java \
src/main/java/io/cameleer/saas/portal/TenantPortalController.java
git commit -m "feat(audit): add audit logging to tenant CA certificate operations"
Task 7: AccountService — Audit Account Security Operations
Files:
-
Modify:
src/main/java/io/cameleer/saas/account/AccountService.java -
Step 1: Inject AuditService
Add imports:
import io.cameleer.saas.audit.AuditAction;
import io.cameleer.saas.audit.AuditService;
import java.util.UUID;
Add AuditService auditService field and constructor parameter.
- Step 2: Add audit logging to updateDisplayName
After logtoClient.updateUserProfile(...) add:
auditService.log(resolveUUID(userId), null, null,
AuditAction.PROFILE_UPDATED, userId,
null, null, "SUCCESS",
Map.of("name", name.trim()));
Add helper:
private UUID resolveUUID(String id) {
try {
return UUID.fromString(id);
} catch (Exception e) {
return UUID.nameUUIDFromBytes(id.getBytes());
}
}
- Step 3: Add audit logging to changePassword
After logtoClient.updateUserPassword(userId, newPassword); (line 78, before the notification try block), add:
auditService.log(resolveUUID(userId), null, null,
AuditAction.PASSWORD_CHANGED, userId,
null, null, "SUCCESS", null);
- Step 4: Add audit logging to verifyAndEnableTotp
After logtoClient.createTotpVerification(userId, secret); add:
auditService.log(resolveUUID(userId), null, null,
AuditAction.MFA_TOTP_ENABLED, userId,
null, null, "SUCCESS", null);
- Step 5: Add audit logging to generateBackupCodes
After logtoClient.createBackupCodes(userId); add:
auditService.log(resolveUUID(userId), null, null,
AuditAction.MFA_BACKUP_CODES_GENERATED, userId,
null, null, "SUCCESS", null);
- Step 6: Add audit logging to removeMfa
After the for loop that deletes all verifications, add:
auditService.log(resolveUUID(userId), null, null,
AuditAction.MFA_TOTP_REMOVED, userId,
null, null, "SUCCESS", null);
- Step 7: Add audit logging to renamePasskey
After logtoClient.renameMfaVerification(...) add:
auditService.log(resolveUUID(userId), null, null,
AuditAction.PASSKEY_RENAMED, credentialId,
null, null, "SUCCESS",
Map.of("name", name));
- Step 8: Add audit logging to deletePasskey
After logtoClient.deleteMfaVerification(userId, credentialId); add:
auditService.log(resolveUUID(userId), null, null,
AuditAction.PASSKEY_DELETED, credentialId,
null, null, "SUCCESS", null);
- Step 9: Add audit logging to setMfaMethodPreference
After logtoClient.updateUserCustomData(...) add:
auditService.log(resolveUUID(userId), null, null,
AuditAction.MFA_PREFERENCE_CHANGED, userId,
null, null, "SUCCESS",
Map.of("preference", preference));
- Step 10: Build and commit
Run: ./mvnw compile -q
git add src/main/java/io/cameleer/saas/account/AccountService.java
git commit -m "feat(audit): add audit logging to account security operations"
Task 8: TenantPortalService — Audit Team & Server Operations
Files:
-
Modify:
src/main/java/io/cameleer/saas/portal/TenantPortalService.java -
Modify:
src/main/java/io/cameleer/saas/portal/TenantPortalController.java -
Step 1: Inject AuditService into TenantPortalService
Add imports:
import io.cameleer.saas.audit.AuditAction;
import io.cameleer.saas.audit.AuditService;
Add AuditService auditService field and constructor parameter.
- Step 2: Add actorId + audit logging to inviteTeamMember
Change signature to inviteTeamMember(String email, String roleName, UUID actorId).
After return logtoClient.createAndInviteUser(...), capture the result and add:
String userId = logtoClient.createAndInviteUser(email, orgId, resolvedRoleId);
auditService.log(actorId, null, tenant.getId(),
AuditAction.TEAM_INVITE, userId,
null, null, "SUCCESS",
Map.of("email", email, "role", roleName != null ? roleName : ""));
return userId;
- Step 3: Add actorId + audit logging to removeTeamMember
Change signature to removeTeamMember(String userId, UUID actorId).
After the remove operations, add:
auditService.log(actorId, null, tenant.getId(),
AuditAction.TEAM_REMOVE, userId,
null, null, "SUCCESS", null);
- Step 4: Add actorId + audit logging to changeTeamMemberRole
Change signature to changeTeamMemberRole(String userId, String roleName, UUID actorId).
After logtoClient.assignOrganizationRole(...), add:
auditService.log(actorId, null, tenant.getId(),
AuditAction.TEAM_ROLE_CHANGE, userId,
null, null, "SUCCESS",
Map.of("role", roleName));
- Step 5: Add actorId + audit logging to resetTeamMemberPassword
Change signature to resetTeamMemberPassword(String userId, String newPassword, UUID actorId).
After logtoClient.updateUserPassword(userId, newPassword);, add:
auditService.log(actorId, null, tenant.getId(),
AuditAction.TEAM_MEMBER_PASSWORD_RESET, userId,
null, null, "SUCCESS", null);
- Step 6: Add actorId + audit logging to resetTeamMemberMfa
Change signature to resetTeamMemberMfa(String userId, UUID actorId).
After logtoClient.deleteAllMfaVerifications(userId);, add:
auditService.log(actorId, null, tenant.getId(),
AuditAction.TEAM_MEMBER_MFA_RESET, userId,
null, null, "SUCCESS", null);
- Step 7: Add actorId + audit logging to resetServerAdminPassword
Change signature to resetServerAdminPassword(String newPassword, UUID actorId).
After serverApiClient.resetServerAdminPassword(endpoint, newPassword);, add:
auditService.log(actorId, null, tenant.getId(),
AuditAction.SERVER_ADMIN_PASSWORD_RESET, tenant.getSlug(),
null, null, "SUCCESS", null);
- Step 8: Add actorId + audit logging to restartServer
Change signature to restartServer(UUID actorId).
Resolve tenant, then after the restart logic, add:
auditService.log(actorId, null, tenant.getId(),
AuditAction.SERVER_RESTARTED, tenant.getSlug(),
null, null, "SUCCESS", null);
- Step 9: Add actorId + audit logging to upgradeServer
Change signature to upgradeServer(UUID actorId).
After the upgrade logic, add:
auditService.log(actorId, null, tenant.getId(),
AuditAction.SERVER_UPGRADED, tenant.getSlug(),
null, null, "SUCCESS", null);
- Step 10: Add actorId + audit logging to updateTenantSettings
Change signature to updateTenantSettings(Map<String, Object> updates, UUID actorId).
After tenantService.save(tenant);, add:
auditService.log(actorId, null, tenant.getId(),
AuditAction.TENANT_AUTH_SETTINGS_UPDATED, tenant.getSlug(),
null, null, "SUCCESS", updates);
- Step 11: Update TenantPortalController
Add @AuthenticationPrincipal Jwt jwt to all team/server endpoints that don't have it.
The resolveActorId helper was added in Task 6.
Update calls (pass resolveActorId(jwt) as the last argument):
// inviteTeamMember:
String userId = portalService.inviteTeamMember(body.email(), body.roleId(), resolveActorId(jwt));
// removeTeamMember:
portalService.removeTeamMember(userId, resolveActorId(jwt));
// changeTeamMemberRole:
portalService.changeTeamMemberRole(userId, body.roleId(), resolveActorId(jwt));
// resetTeamMemberPassword:
portalService.resetTeamMemberPassword(userId, body.password(), resolveActorId(jwt));
// resetTeamMemberMfa:
portalService.resetTeamMemberMfa(userId, resolveActorId(jwt));
// resetServerAdminPassword:
portalService.resetServerAdminPassword(body.password(), resolveActorId(jwt));
// restartServer:
portalService.restartServer(resolveActorId(jwt));
// upgradeServer:
portalService.upgradeServer(resolveActorId(jwt));
// updateAuthSettings:
portalService.updateTenantSettings(updates, resolveActorId(jwt));
- Step 12: Build and commit
Run: ./mvnw compile -q
git add src/main/java/io/cameleer/saas/portal/TenantPortalService.java \
src/main/java/io/cameleer/saas/portal/TenantPortalController.java
git commit -m "feat(audit): add audit logging to tenant team and server operations"
Task 9: TenantSsoService — Audit SSO Connector Operations
Files:
-
Modify:
src/main/java/io/cameleer/saas/portal/TenantSsoService.java -
Modify:
src/main/java/io/cameleer/saas/portal/TenantSsoController.java -
Step 1: Inject AuditService into TenantSsoService
Add imports:
import io.cameleer.saas.audit.AuditAction;
import io.cameleer.saas.audit.AuditService;
Add AuditService auditService field and constructor parameter.
- Step 2: Add actorId + audit logging to createConnector
Change signature to add UUID actorId as last parameter.
After logtoClient.linkSsoConnectorToOrg(...), add:
UUID tenantId = TenantContext.getTenantId();
auditService.log(actorId, null, tenantId,
AuditAction.SSO_CONNECTOR_CREATED, connectorId,
null, null, "SUCCESS",
Map.of("providerName", providerName, "connectorName", connectorName));
- Step 3: Add actorId + audit logging to updateConnector
Change signature to add UUID actorId as last parameter.
After logtoClient.updateSsoConnector(...), add:
UUID tenantId = TenantContext.getTenantId();
auditService.log(actorId, null, tenantId,
AuditAction.SSO_CONNECTOR_UPDATED, connectorId,
null, null, "SUCCESS", null);
- Step 4: Add actorId + audit logging to deleteConnector
Change signature to add UUID actorId as last parameter.
After logtoClient.deleteSsoConnector(connectorId);, add:
UUID tenantId = TenantContext.getTenantId();
auditService.log(actorId, null, tenantId,
AuditAction.SSO_CONNECTOR_DELETED, connectorId,
null, null, "SUCCESS", null);
- Step 5: Update TenantSsoController
Add @AuthenticationPrincipal Jwt jwt and resolveActorId to controller.
Add imports:
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import java.util.UUID;
Pass actorId to service calls:
@PostMapping
public ResponseEntity<Map<String, Object>> create(@RequestBody CreateSsoConnectorRequest request,
@AuthenticationPrincipal Jwt jwt) {
var connector = ssoService.createConnector(
request.providerName(), request.connectorName(),
request.config(), request.domains(), resolveActorId(jwt));
return ResponseEntity.status(HttpStatus.CREATED).body(connector);
}
@PatchMapping("/{connectorId}")
public ResponseEntity<Map<String, Object>> update(@PathVariable String connectorId,
@RequestBody Map<String, Object> updates,
@AuthenticationPrincipal Jwt jwt) {
return ResponseEntity.ok(ssoService.updateConnector(connectorId, updates, resolveActorId(jwt)));
}
@DeleteMapping("/{connectorId}")
public ResponseEntity<Void> delete(@PathVariable String connectorId,
@AuthenticationPrincipal Jwt jwt) {
ssoService.deleteConnector(connectorId, 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());
}
}
- Step 6: Build and commit
Run: ./mvnw compile -q
git add src/main/java/io/cameleer/saas/portal/TenantSsoService.java \
src/main/java/io/cameleer/saas/portal/TenantSsoController.java
git commit -m "feat(audit): add audit logging to SSO connector operations"
Task 10: VendorTenantService — Audit Server Restart/Upgrade
Files:
-
Modify:
src/main/java/io/cameleer/saas/vendor/VendorTenantService.java -
Modify:
src/main/java/io/cameleer/saas/vendor/VendorTenantController.java -
Step 1: Add actorId to restartServer and upgradeServer
VendorTenantService already injects AuditService.
Change restartServer(UUID tenantId) to restartServer(UUID tenantId, UUID actorId).
After the restart logic completes, add:
auditService.log(actorId, null, tenantId,
AuditAction.SERVER_RESTARTED, tenant.getSlug(),
null, null, "SUCCESS", null);
Change upgradeServer(UUID tenantId) to upgradeServer(UUID tenantId, UUID actorId).
After the upgrade logic, add:
auditService.log(actorId, null, tenantId,
AuditAction.SERVER_UPGRADED, tenant.getSlug(),
null, null, "SUCCESS", null);
- Step 2: Update VendorTenantController
Add @AuthenticationPrincipal Jwt jwt to restart and upgrade endpoints:
@PostMapping("/{id}/restart")
public ResponseEntity<Void> restart(@PathVariable UUID id,
@AuthenticationPrincipal Jwt jwt) {
try {
vendorTenantService.restartServer(id, resolveActorId(jwt));
return ResponseEntity.noContent().build();
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
}
}
@PostMapping("/{id}/upgrade")
public ResponseEntity<Void> upgrade(@PathVariable UUID id,
@AuthenticationPrincipal Jwt jwt) {
try {
vendorTenantService.upgradeServer(id, resolveActorId(jwt));
return ResponseEntity.noContent().build();
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
}
}
- Step 3: Build and commit
Run: ./mvnw compile -q
git add src/main/java/io/cameleer/saas/vendor/VendorTenantService.java \
src/main/java/io/cameleer/saas/vendor/VendorTenantController.java
git commit -m "feat(audit): add audit logging to vendor server restart/upgrade"
Task 11: Flyway Migration — Audit Log Immutability
Files:
-
Create:
src/main/resources/db/migration/V004__audit_log_immutability.sql -
Step 1: Create the migration
-- V004: Protect audit_log from tampering (SOC 2 CC7.2/CC7.3)
-- Prevents UPDATE and DELETE on audit_log rows via database triggers.
CREATE OR REPLACE FUNCTION audit_log_prevent_modify()
RETURNS TRIGGER AS $$
BEGIN
RAISE EXCEPTION 'audit_log is immutable: % operations are not allowed', TG_OP;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER audit_log_no_update
BEFORE UPDATE ON audit_log
FOR EACH ROW
EXECUTE FUNCTION audit_log_prevent_modify();
CREATE TRIGGER audit_log_no_delete
BEFORE DELETE ON audit_log
FOR EACH ROW
EXECUTE FUNCTION audit_log_prevent_modify();
- Step 2: Build and commit
Run: ./mvnw compile -q
git add src/main/resources/db/migration/V004__audit_log_immutability.sql
git commit -m "feat(audit): add Flyway migration for audit_log immutability triggers"
Coverage Summary
| Category | Operations Covered | Enum Values |
|---|---|---|
| Vendor admin lifecycle | create, remove, password reset, MFA reset | 4 |
| Platform auth policy | update MFA/passkey policy | 1 |
| Email connector | save, delete, toggle registration | 3 |
| Platform certificates | stage, activate, restore, discard | 4 |
| Tenant CA certificates | stage, activate, delete | 3 |
| Account security | profile, password, TOTP, backup codes, passkeys, preference | 8 |
| Team management | invite, remove, role change, password reset, MFA reset | 5 |
| Server operations (tenant) | restart, upgrade, admin password reset | 3 |
| Server operations (vendor) | restart, upgrade | 2 |
| Tenant settings | auth settings update | 1 |
| SSO connectors | create, update, delete | 3 |
| DB integrity | immutability triggers | — |
| Total | 37 operations | 30 new enum values |