1147 lines
36 KiB
Markdown
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** |
|