Files
cameleer-saas/docs/superpowers/plans/2026-04-29-soc2-audit-logging.md
hsiegeln 5e19e07257
Some checks failed
CI / build (push) Failing after 1m8s
CI / docker (push) Has been skipped
docs: add SOC 2 audit logging implementation plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-29 11:44:38 +02:00

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