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

1147 lines
36 KiB
Markdown

# 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:
```java
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**
```bash
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:
```java
import io.cameleer.saas.audit.AuditAction;
import io.cameleer.saas.audit.AuditService;
import java.util.UUID;
```
Add field and constructor parameter:
```java
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:
```java
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:
```java
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:
```java
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:
```java
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):
```java
private UUID resolveActorId(Jwt jwt) {
try {
return UUID.fromString(jwt.getSubject());
} catch (Exception e) {
return UUID.nameUUIDFromBytes(jwt.getSubject().getBytes());
}
}
```
Updated controller methods:
```java
@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`
```bash
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:
```java
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:
```java
@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`
```bash
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:
```java
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:
```java
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:
```java
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:
```java
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:
```java
// 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`
```bash
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:
```java
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:
```java
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:
```java
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:
```java
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:
```java
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:
```java
// activate:
certificateService.activate(resolveActorId(jwt));
// restore:
certificateService.restore(resolveActorId(jwt));
// discardStaged:
certificateService.discardStaged(resolveActorId(jwt));
```
- [ ] **Step 7: Build and commit**
Run: `./mvnw compile -q`
```bash
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:
```java
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:
```java
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:
```java
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:
```java
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:
```java
private UUID resolveActorId(Jwt jwt) {
try {
return UUID.fromString(jwt.getSubject());
} catch (Exception e) {
return UUID.nameUUIDFromBytes(jwt.getSubject().getBytes());
}
}
```
Update calls:
```java
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`
```bash
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:
```java
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:
```java
auditService.log(resolveUUID(userId), null, null,
AuditAction.PROFILE_UPDATED, userId,
null, null, "SUCCESS",
Map.of("name", name.trim()));
```
Add helper:
```java
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:
```java
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:
```java
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:
```java
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:
```java
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:
```java
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:
```java
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:
```java
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`
```bash
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:
```java
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:
```java
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:
```java
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:
```java
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:
```java
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:
```java
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:
```java
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:
```java
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:
```java
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:
```java
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):
```java
// 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`
```bash
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:
```java
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:
```java
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:
```java
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:
```java
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:
```java
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import java.util.UUID;
```
Pass actorId to service calls:
```java
@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`
```bash
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:
```java
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:
```java
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:
```java
@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`
```bash
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**
```sql
-- 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`
```bash
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** |