feat(audit): add SOC 2 audit logging to tenant CA certs, account security, team management, SSO, and server operations

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-29 11:29:55 +02:00
parent 88733d76c0
commit da52707aec
7 changed files with 216 additions and 51 deletions

View File

@@ -1,5 +1,7 @@
package io.cameleer.saas.account;
import io.cameleer.saas.audit.AuditAction;
import io.cameleer.saas.audit.AuditService;
import io.cameleer.saas.identity.LogtoManagementClient;
import io.cameleer.saas.notification.PasswordResetNotificationService;
import org.slf4j.Logger;
@@ -15,6 +17,7 @@ import java.security.SecureRandom;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@Service
public class AccountService {
@@ -24,11 +27,22 @@ public class AccountService {
private final LogtoManagementClient logtoClient;
private final PasswordResetNotificationService passwordNotificationService;
private final AuditService auditService;
public AccountService(LogtoManagementClient logtoClient,
PasswordResetNotificationService passwordNotificationService) {
PasswordResetNotificationService passwordNotificationService,
AuditService auditService) {
this.logtoClient = logtoClient;
this.passwordNotificationService = passwordNotificationService;
this.auditService = auditService;
}
private UUID resolveUUID(String id) {
try {
return UUID.fromString(id);
} catch (Exception e) {
return UUID.nameUUIDFromBytes(id.getBytes());
}
}
// --- Records ---
@@ -60,6 +74,8 @@ public class AccountService {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Display name must not be blank");
}
logtoClient.updateUserProfile(userId, Map.of("name", name.trim()));
auditService.log(resolveUUID(userId), null, null, AuditAction.PROFILE_UPDATED,
userId, null, null, "SUCCESS", Map.of("name", name.trim()));
}
// --- Password ---
@@ -76,6 +92,8 @@ public class AccountService {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Current password is incorrect");
}
logtoClient.updateUserPassword(userId, newPassword);
auditService.log(resolveUUID(userId), null, null, AuditAction.PASSWORD_CHANGED,
userId, null, null, "SUCCESS", null);
// Send confirmation email asynchronously
try {
@@ -135,6 +153,8 @@ public class AccountService {
public boolean verifyAndEnableTotp(String userId, String secret, String code) {
if (!verifyTotpCode(secret, code)) return false;
logtoClient.createTotpVerification(userId, secret);
auditService.log(resolveUUID(userId), null, null, AuditAction.MFA_TOTP_ENABLED,
userId, null, null, "SUCCESS", null);
return true;
}
@@ -152,6 +172,8 @@ public class AccountService {
var result = logtoClient.createBackupCodes(userId);
@SuppressWarnings("unchecked")
List<String> codes = (List<String>) result.get("codes");
auditService.log(resolveUUID(userId), null, null, AuditAction.MFA_BACKUP_CODES_GENERATED,
userId, null, null, "SUCCESS", null);
return new BackupCodesData(codes != null ? codes : List.of());
}
@@ -160,6 +182,8 @@ public class AccountService {
for (var v : verifications) {
logtoClient.deleteMfaVerification(userId, String.valueOf(v.get("id")));
}
auditService.log(resolveUUID(userId), null, null, AuditAction.MFA_TOTP_REMOVED,
userId, null, null, "SUCCESS", null);
}
// --- Passkeys ---
@@ -184,6 +208,8 @@ public class AccountService {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Passkey not found");
}
logtoClient.renameMfaVerification(userId, credentialId, name);
auditService.log(resolveUUID(userId), null, null, AuditAction.PASSKEY_RENAMED,
credentialId, null, null, "SUCCESS", Map.of("name", name));
}
public void deletePasskey(String userId, String credentialId) {
@@ -194,6 +220,8 @@ public class AccountService {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Passkey not found");
}
logtoClient.deleteMfaVerification(userId, credentialId);
auditService.log(resolveUUID(userId), null, null, AuditAction.PASSKEY_DELETED,
credentialId, null, null, "SUCCESS", null);
}
// --- MFA Preference ---
@@ -203,6 +231,8 @@ public class AccountService {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid MFA preference: must be 'totp' or 'webauthn'");
}
logtoClient.updateUserCustomData(userId, Map.of("mfa_method_preference", preference));
auditService.log(resolveUUID(userId), null, null, AuditAction.MFA_PREFERENCE_CHANGED,
userId, null, null, "SUCCESS", Map.of("preference", preference));
}
// --- TOTP helpers (moved from TenantPortalService) ---

View File

@@ -1,12 +1,55 @@
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
ENVIRONMENT_CREATE, ENVIRONMENT_UPDATE, ENVIRONMENT_DELETE,
APP_CREATE, APP_DEPLOY, APP_PROMOTE, APP_ROLLBACK, APP_SCALE, APP_STOP, APP_DELETE,
// Secrets
SECRET_CREATE, SECRET_READ, SECRET_UPDATE, SECRET_DELETE, SECRET_ROTATE,
// Config
CONFIG_UPDATE,
// Team management
TEAM_INVITE, TEAM_REMOVE, TEAM_ROLE_CHANGE,
LICENSE_GENERATE, LICENSE_REVOKE
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
}

View File

@@ -1,5 +1,7 @@
package io.cameleer.saas.certificate;
import io.cameleer.saas.audit.AuditAction;
import io.cameleer.saas.audit.AuditService;
import io.cameleer.saas.provisioning.DockerCertificateManager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -13,6 +15,7 @@ import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@Service
@@ -22,10 +25,13 @@ public class TenantCaCertService {
private final TenantCaCertRepository caCertRepository;
private final CertificateManager certManager;
private final AuditService auditService;
public TenantCaCertService(TenantCaCertRepository caCertRepository, CertificateManager certManager) {
public TenantCaCertService(TenantCaCertRepository caCertRepository, CertificateManager certManager,
AuditService auditService) {
this.caCertRepository = caCertRepository;
this.certManager = certManager;
this.auditService = auditService;
}
public List<TenantCaCertEntity> listForTenant(UUID tenantId) {
@@ -33,7 +39,7 @@ public class TenantCaCertService {
}
@Transactional
public TenantCaCertEntity stage(UUID tenantId, String label, byte[] certPem) {
public TenantCaCertEntity stage(UUID tenantId, String label, byte[] certPem, UUID actorId) {
// Parse and validate
X509Certificate cert;
try {
@@ -64,11 +70,14 @@ public class TenantCaCertService {
var saved = caCertRepository.save(entity);
log.info("Staged tenant CA cert for tenant {}: subject={}", tenantId, entity.getSubject());
auditService.log(actorId, null, tenantId, AuditAction.TENANT_CA_CERT_STAGED,
saved.getId().toString(), null, null, "SUCCESS",
Map.of("subject", saved.getSubject(), "fingerprint", saved.getFingerprint()));
return saved;
}
@Transactional
public TenantCaCertEntity activate(UUID tenantId, UUID certId) {
public TenantCaCertEntity activate(UUID tenantId, UUID certId, UUID actorId) {
var entity = caCertRepository.findById(certId)
.orElseThrow(() -> new IllegalArgumentException("CA certificate not found"));
if (!entity.getTenantId().equals(tenantId)) {
@@ -83,11 +92,14 @@ public class TenantCaCertService {
rebuildCaBundle();
log.info("Activated tenant CA cert {} for tenant {}", certId, tenantId);
auditService.log(actorId, null, tenantId, AuditAction.TENANT_CA_CERT_ACTIVATED,
certId.toString(), null, null, "SUCCESS",
Map.of("subject", entity.getSubject()));
return entity;
}
@Transactional
public void delete(UUID tenantId, UUID certId) {
public void delete(UUID tenantId, UUID certId, UUID actorId) {
var entity = caCertRepository.findById(certId)
.orElseThrow(() -> new IllegalArgumentException("CA certificate not found"));
if (!entity.getTenantId().equals(tenantId)) {
@@ -101,6 +113,9 @@ public class TenantCaCertService {
rebuildCaBundle();
}
log.info("Deleted tenant CA cert {} for tenant {}", certId, tenantId);
auditService.log(actorId, null, tenantId, AuditAction.TENANT_CA_CERT_DELETED,
certId.toString(), null, null, "SUCCESS",
Map.of("wasActive", wasActive));
}
/**

View File

@@ -26,6 +26,7 @@ import java.util.Map;
import java.util.Set;
import java.util.UUID;
@RestController
@RequestMapping("/api/tenant")
public class TenantPortalController {
@@ -52,6 +53,16 @@ public class TenantPortalController {
public record TotpVerifyRequest(String secret, String code) {}
// --- Helpers ---
private UUID resolveActorId(Jwt jwt) {
try {
return UUID.fromString(jwt.getSubject());
} catch (Exception e) {
return UUID.nameUUIDFromBytes(jwt.getSubject().getBytes());
}
}
// --- Endpoints ---
@GetMapping("/dashboard")
@@ -75,31 +86,35 @@ public class TenantPortalController {
@PreAuthorize("hasAuthority('SCOPE_tenant:manage')")
@PostMapping("/team/invite")
public ResponseEntity<Map<String, String>> inviteTeamMember(@RequestBody InviteRequest body) {
String userId = portalService.inviteTeamMember(body.email(), body.roleId());
public ResponseEntity<Map<String, String>> inviteTeamMember(@AuthenticationPrincipal Jwt jwt,
@RequestBody InviteRequest body) {
String userId = portalService.inviteTeamMember(body.email(), body.roleId(), resolveActorId(jwt));
return ResponseEntity.ok(Map.of("userId", userId != null ? userId : ""));
}
@PreAuthorize("hasAuthority('SCOPE_tenant:manage')")
@DeleteMapping("/team/{userId}")
public ResponseEntity<Void> removeTeamMember(@PathVariable String userId) {
portalService.removeTeamMember(userId);
public ResponseEntity<Void> removeTeamMember(@AuthenticationPrincipal Jwt jwt,
@PathVariable String userId) {
portalService.removeTeamMember(userId, resolveActorId(jwt));
return ResponseEntity.noContent().build();
}
@PreAuthorize("hasAuthority('SCOPE_tenant:manage')")
@PatchMapping("/team/{userId}/role")
public ResponseEntity<Void> changeTeamMemberRole(@PathVariable String userId,
public ResponseEntity<Void> changeTeamMemberRole(@AuthenticationPrincipal Jwt jwt,
@PathVariable String userId,
@RequestBody RoleChangeRequest body) {
portalService.changeTeamMemberRole(userId, body.roleId());
portalService.changeTeamMemberRole(userId, body.roleId(), resolveActorId(jwt));
return ResponseEntity.ok().build();
}
@PreAuthorize("hasAuthority('SCOPE_tenant:manage')")
@PostMapping("/server/admin-password")
public ResponseEntity<Void> resetServerAdminPassword(@RequestBody PasswordChangeRequest body) {
public ResponseEntity<Void> resetServerAdminPassword(@AuthenticationPrincipal Jwt jwt,
@RequestBody PasswordChangeRequest body) {
try {
portalService.resetServerAdminPassword(body.password());
portalService.resetServerAdminPassword(body.password(), resolveActorId(jwt));
return ResponseEntity.noContent().build();
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().build();
@@ -110,10 +125,11 @@ public class TenantPortalController {
@PreAuthorize("hasAuthority('SCOPE_tenant:manage')")
@PostMapping("/team/{userId}/password")
public ResponseEntity<Void> resetTeamMemberPassword(@PathVariable String userId,
public ResponseEntity<Void> resetTeamMemberPassword(@AuthenticationPrincipal Jwt jwt,
@PathVariable String userId,
@RequestBody PasswordChangeRequest body) {
try {
portalService.resetTeamMemberPassword(userId, body.password());
portalService.resetTeamMemberPassword(userId, body.password(), resolveActorId(jwt));
return ResponseEntity.noContent().build();
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().build();
@@ -122,15 +138,15 @@ public class TenantPortalController {
@PreAuthorize("hasAuthority('SCOPE_tenant:manage')")
@PostMapping("/server/restart")
public ResponseEntity<Void> restartServer() {
portalService.restartServer();
public ResponseEntity<Void> restartServer(@AuthenticationPrincipal Jwt jwt) {
portalService.restartServer(resolveActorId(jwt));
return ResponseEntity.noContent().build();
}
@PreAuthorize("hasAuthority('SCOPE_tenant:manage')")
@PostMapping("/server/upgrade")
public ResponseEntity<Void> upgradeServer() {
portalService.upgradeServer();
public ResponseEntity<Void> upgradeServer(@AuthenticationPrincipal Jwt jwt) {
portalService.upgradeServer(resolveActorId(jwt));
return ResponseEntity.noContent().build();
}
@@ -175,9 +191,10 @@ public class TenantPortalController {
@PreAuthorize("hasAuthority('SCOPE_tenant:manage')")
@DeleteMapping("/users/{userId}/mfa")
public ResponseEntity<Void> resetTeamMemberMfa(@PathVariable String userId) {
public ResponseEntity<Void> resetTeamMemberMfa(@AuthenticationPrincipal Jwt jwt,
@PathVariable String userId) {
try {
portalService.resetTeamMemberMfa(userId);
portalService.resetTeamMemberMfa(userId, resolveActorId(jwt));
return ResponseEntity.noContent().build();
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().build();
@@ -231,8 +248,9 @@ public class TenantPortalController {
@PreAuthorize("hasAuthority('SCOPE_tenant:manage')")
@PatchMapping("/auth-settings")
public ResponseEntity<Void> updateAuthSettings(@RequestBody Map<String, Object> updates) {
portalService.updateTenantSettings(updates);
public ResponseEntity<Void> updateAuthSettings(@AuthenticationPrincipal Jwt jwt,
@RequestBody Map<String, Object> updates) {
portalService.updateTenantSettings(updates, resolveActorId(jwt));
return ResponseEntity.ok().build();
}
@@ -284,11 +302,12 @@ public class TenantPortalController {
@PreAuthorize("hasAuthority('SCOPE_tenant:manage')")
@PostMapping("/ca")
public ResponseEntity<CaCertResponse> stageCaCert(
@AuthenticationPrincipal Jwt jwt,
@RequestParam("cert") MultipartFile certFile,
@RequestParam(value = "label", required = false) String label) {
try {
UUID tenantId = TenantContext.getTenantId();
var entity = caCertService.stage(tenantId, label, certFile.getBytes());
var entity = caCertService.stage(tenantId, label, certFile.getBytes(), resolveActorId(jwt));
return ResponseEntity.ok(CaCertResponse.from(entity));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().build();
@@ -299,10 +318,11 @@ public class TenantPortalController {
@PreAuthorize("hasAuthority('SCOPE_tenant:manage')")
@PostMapping("/ca/{id}/activate")
public ResponseEntity<CaCertResponse> activateCaCert(@PathVariable UUID id) {
public ResponseEntity<CaCertResponse> activateCaCert(@AuthenticationPrincipal Jwt jwt,
@PathVariable UUID id) {
try {
UUID tenantId = TenantContext.getTenantId();
var entity = caCertService.activate(tenantId, id);
var entity = caCertService.activate(tenantId, id, resolveActorId(jwt));
return ResponseEntity.ok(CaCertResponse.from(entity));
} catch (IllegalArgumentException | IllegalStateException e) {
return ResponseEntity.badRequest().build();
@@ -311,10 +331,11 @@ public class TenantPortalController {
@PreAuthorize("hasAuthority('SCOPE_tenant:manage')")
@DeleteMapping("/ca/{id}")
public ResponseEntity<Void> deleteCaCert(@PathVariable UUID id) {
public ResponseEntity<Void> deleteCaCert(@AuthenticationPrincipal Jwt jwt,
@PathVariable UUID id) {
try {
UUID tenantId = TenantContext.getTenantId();
caCertService.delete(tenantId, id);
caCertService.delete(tenantId, id, resolveActorId(jwt));
return ResponseEntity.noContent().build();
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();

View File

@@ -1,6 +1,8 @@
package io.cameleer.saas.portal;
import io.cameleer.saas.account.AccountService;
import io.cameleer.saas.audit.AuditAction;
import io.cameleer.saas.audit.AuditService;
import io.cameleer.saas.config.TenantContext;
import io.cameleer.saas.identity.LogtoManagementClient;
import io.cameleer.saas.identity.ServerApiClient;
@@ -37,6 +39,7 @@ public class TenantPortalService {
private final ProvisioningProperties provisioningProps;
private final VendorTenantService vendorTenantService;
private final AccountService accountService;
private final AuditService auditService;
public TenantPortalService(TenantService tenantService,
LicenseService licenseService,
@@ -45,7 +48,8 @@ public class TenantPortalService {
TenantProvisioner tenantProvisioner,
ProvisioningProperties provisioningProps,
@Lazy VendorTenantService vendorTenantService,
AccountService accountService) {
AccountService accountService,
AuditService auditService) {
this.tenantService = tenantService;
this.licenseService = licenseService;
this.serverApiClient = serverApiClient;
@@ -54,6 +58,7 @@ public class TenantPortalService {
this.provisioningProps = provisioningProps;
this.vendorTenantService = vendorTenantService;
this.accountService = accountService;
this.auditService = auditService;
}
// --- Inner records ---
@@ -198,17 +203,20 @@ public class TenantPortalService {
}).toList();
}
public String inviteTeamMember(String email, String roleName) {
public String inviteTeamMember(String email, String roleName, UUID actorId) {
TenantEntity tenant = resolveTenant();
String orgId = tenant.getLogtoOrgId();
if (orgId == null || orgId.isBlank()) {
throw new IllegalStateException("Tenant has no Logto organization configured");
}
String resolvedRoleId = resolveOrgRoleId(roleName);
return logtoClient.createAndInviteUser(email, orgId, resolvedRoleId);
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;
}
public void removeTeamMember(String userId) {
public void removeTeamMember(String userId, UUID actorId) {
TenantEntity tenant = resolveTenant();
String orgId = tenant.getLogtoOrgId();
if (orgId == null || orgId.isBlank()) {
@@ -222,9 +230,11 @@ public class TenantPortalService {
log.info("User {} has no remaining org memberships — deleting from Logto", userId);
logtoClient.deleteUser(userId);
}
auditService.log(actorId, null, tenant.getId(), AuditAction.TEAM_REMOVE,
userId, null, null, "SUCCESS", null);
}
public void changeTeamMemberRole(String userId, String roleName) {
public void changeTeamMemberRole(String userId, String roleName, UUID actorId) {
TenantEntity tenant = resolveTenant();
String orgId = tenant.getLogtoOrgId();
if (orgId == null || orgId.isBlank()) {
@@ -232,6 +242,8 @@ public class TenantPortalService {
}
String resolvedRoleId = resolveOrgRoleId(roleName);
logtoClient.assignOrganizationRole(orgId, userId, resolvedRoleId);
auditService.log(actorId, null, tenant.getId(), AuditAction.TEAM_ROLE_CHANGE,
userId, null, null, "SUCCESS", Map.of("role", roleName != null ? roleName : ""));
}
/** Resolve a role name (e.g. "viewer") to a Logto organization role ID. */
@@ -244,7 +256,7 @@ public class TenantPortalService {
return resolved;
}
public void resetServerAdminPassword(String newPassword) {
public void resetServerAdminPassword(String newPassword, UUID actorId) {
TenantEntity tenant = resolveTenant();
String endpoint = tenant.getServerEndpoint();
if (endpoint == null || endpoint.isBlank()) {
@@ -254,6 +266,8 @@ public class TenantPortalService {
throw new IllegalArgumentException("Password must be at least 8 characters");
}
serverApiClient.resetServerAdminPassword(endpoint, newPassword);
auditService.log(actorId, null, tenant.getId(), AuditAction.SERVER_ADMIN_PASSWORD_RESET,
tenant.getSlug(), null, null, "SUCCESS", null);
}
public void changePassword(String userId, String newPassword) {
@@ -261,7 +275,7 @@ public class TenantPortalService {
logtoClient.updateUserPassword(userId, newPassword);
}
public void resetTeamMemberPassword(String userId, String newPassword) {
public void resetTeamMemberPassword(String userId, String newPassword, UUID actorId) {
TenantEntity tenant = resolveTenant();
String orgId = tenant.getLogtoOrgId();
if (orgId == null || orgId.isBlank()) {
@@ -278,6 +292,8 @@ public class TenantPortalService {
throw new IllegalArgumentException("Password must be at least 8 characters");
}
logtoClient.updateUserPassword(userId, newPassword);
auditService.log(actorId, null, tenant.getId(), AuditAction.TEAM_MEMBER_PASSWORD_RESET,
userId, null, null, "SUCCESS", null);
}
public TenantSettingsData getSettings() {
@@ -291,7 +307,7 @@ public class TenantPortalService {
);
}
public void restartServer() {
public void restartServer(UUID actorId) {
TenantEntity tenant = resolveTenant();
if (!tenantProvisioner.isAvailable()) return;
@@ -306,13 +322,17 @@ public class TenantPortalService {
String token = license != null ? license.getToken() : "";
vendorTenantService.provisionAsync(
tenant.getId(), tenant.getSlug(), tenant.getTier().name(), token, null);
auditService.log(actorId, null, tenant.getId(), AuditAction.SERVER_RESTARTED,
tenant.getSlug(), null, null, "SUCCESS", null);
return;
}
throw e;
}
auditService.log(actorId, null, tenant.getId(), AuditAction.SERVER_RESTARTED,
tenant.getSlug(), null, null, "SUCCESS", null);
}
public void upgradeServer() {
public void upgradeServer(UUID actorId) {
TenantEntity tenant = resolveTenant();
if (!tenantProvisioner.isAvailable()) return;
@@ -322,6 +342,8 @@ public class TenantPortalService {
String token = license != null ? license.getToken() : "";
vendorTenantService.provisionAsync(
tenant.getId(), tenant.getSlug(), tenant.getTier().name(), token, null);
auditService.log(actorId, null, tenant.getId(), AuditAction.SERVER_UPGRADED,
tenant.getSlug(), null, null, "SUCCESS", null);
}
// --- MFA methods ---
@@ -349,7 +371,7 @@ public class TenantPortalService {
accountService.removeMfa(userId);
}
public void resetTeamMemberMfa(String userId) {
public void resetTeamMemberMfa(String userId, UUID actorId) {
TenantEntity tenant = resolveTenant();
String orgId = tenant.getLogtoOrgId();
if (orgId == null || orgId.isBlank()) {
@@ -363,6 +385,8 @@ public class TenantPortalService {
throw new IllegalArgumentException("User is not a member of this organization");
}
logtoClient.deleteAllMfaVerifications(userId);
auditService.log(actorId, null, tenant.getId(), AuditAction.TEAM_MEMBER_MFA_RESET,
userId, null, null, "SUCCESS", null);
}
// --- Passkey methods ---
@@ -387,7 +411,7 @@ public class TenantPortalService {
accountService.setMfaMethodPreference(userId, preference);
}
public void updateTenantSettings(Map<String, Object> updates) {
public void updateTenantSettings(Map<String, Object> updates, UUID actorId) {
TenantEntity tenant = resolveTenant();
Map<String, Object> settings = new HashMap<>(
tenant.getSettings() != null ? tenant.getSettings() : Map.of());
@@ -411,6 +435,8 @@ public class TenantPortalService {
}
tenant.setSettings(settings);
tenantService.save(tenant);
auditService.log(actorId, null, tenant.getId(), AuditAction.TENANT_AUTH_SETTINGS_UPDATED,
tenant.getSlug(), null, null, "SUCCESS", updates);
}
public record AuthSettingsData(String mfaMode, boolean passkeyEnabled, String passkeyMode) {}

View File

@@ -2,6 +2,8 @@ package io.cameleer.saas.portal;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
@@ -13,6 +15,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@RestController
@RequestMapping("/api/tenant/sso")
@@ -31,16 +34,25 @@ public class TenantSsoController {
List<String> domains
) {}
private UUID resolveActorId(Jwt jwt) {
try {
return UUID.fromString(jwt.getSubject());
} catch (Exception e) {
return UUID.nameUUIDFromBytes(jwt.getSubject().getBytes());
}
}
@GetMapping
public ResponseEntity<List<Map<String, Object>>> list() {
return ResponseEntity.ok(ssoService.listConnectors());
}
@PostMapping
public ResponseEntity<Map<String, Object>> create(@RequestBody CreateSsoConnectorRequest request) {
public ResponseEntity<Map<String, Object>> create(@AuthenticationPrincipal Jwt jwt,
@RequestBody CreateSsoConnectorRequest request) {
var connector = ssoService.createConnector(
request.providerName(), request.connectorName(),
request.config(), request.domains());
request.config(), request.domains(), resolveActorId(jwt));
return ResponseEntity.status(HttpStatus.CREATED).body(connector);
}
@@ -50,14 +62,16 @@ public class TenantSsoController {
}
@PatchMapping("/{connectorId}")
public ResponseEntity<Map<String, Object>> update(@PathVariable String connectorId,
public ResponseEntity<Map<String, Object>> update(@AuthenticationPrincipal Jwt jwt,
@PathVariable String connectorId,
@RequestBody Map<String, Object> updates) {
return ResponseEntity.ok(ssoService.updateConnector(connectorId, updates));
return ResponseEntity.ok(ssoService.updateConnector(connectorId, updates, resolveActorId(jwt)));
}
@DeleteMapping("/{connectorId}")
public ResponseEntity<Void> delete(@PathVariable String connectorId) {
ssoService.deleteConnector(connectorId);
public ResponseEntity<Void> delete(@AuthenticationPrincipal Jwt jwt,
@PathVariable String connectorId) {
ssoService.deleteConnector(connectorId, resolveActorId(jwt));
return ResponseEntity.noContent().build();
}

View File

@@ -1,5 +1,7 @@
package io.cameleer.saas.portal;
import io.cameleer.saas.audit.AuditAction;
import io.cameleer.saas.audit.AuditService;
import io.cameleer.saas.config.TenantContext;
import io.cameleer.saas.identity.LogtoManagementClient;
import io.cameleer.saas.tenant.TenantEntity;
@@ -23,10 +25,13 @@ public class TenantSsoService {
private final LogtoManagementClient logtoClient;
private final TenantService tenantService;
private final AuditService auditService;
public TenantSsoService(LogtoManagementClient logtoClient, TenantService tenantService) {
public TenantSsoService(LogtoManagementClient logtoClient, TenantService tenantService,
AuditService auditService) {
this.logtoClient = logtoClient;
this.tenantService = tenantService;
this.auditService = auditService;
}
/** List SSO connectors linked to the current tenant's organization. */
@@ -49,7 +54,8 @@ public class TenantSsoService {
/** Create an SSO connector and link it to the tenant's organization. */
public Map<String, Object> createConnector(String providerName, String connectorName,
Map<String, Object> config, List<String> domains) {
Map<String, Object> config, List<String> domains,
UUID actorId) {
String orgId = resolveOrgId();
var connector = logtoClient.createSsoConnector(providerName, connectorName, config, domains);
if (connector == null) {
@@ -58,6 +64,10 @@ public class TenantSsoService {
String connectorId = String.valueOf(connector.get("id"));
logtoClient.linkSsoConnectorToOrg(orgId, connectorId);
log.info("Created SSO connector '{}' ({}) and linked to org {}", connectorName, connectorId, orgId);
auditService.log(actorId, null, TenantContext.getTenantId(), AuditAction.SSO_CONNECTOR_CREATED,
connectorId, null, null, "SUCCESS",
Map.of("providerName", providerName != null ? providerName : "",
"connectorName", connectorName != null ? connectorName : ""));
return connector;
}
@@ -68,18 +78,24 @@ public class TenantSsoService {
}
/** Update an SSO connector (validates it belongs to this tenant). */
public Map<String, Object> updateConnector(String connectorId, Map<String, Object> updates) {
public Map<String, Object> updateConnector(String connectorId, Map<String, Object> updates,
UUID actorId) {
validateConnectorBelongsToTenant(connectorId);
return logtoClient.updateSsoConnector(connectorId, updates);
var result = logtoClient.updateSsoConnector(connectorId, updates);
auditService.log(actorId, null, TenantContext.getTenantId(), AuditAction.SSO_CONNECTOR_UPDATED,
connectorId, null, null, "SUCCESS", null);
return result;
}
/** Delete an SSO connector (unlinks from org and deletes). */
public void deleteConnector(String connectorId) {
public void deleteConnector(String connectorId, UUID actorId) {
String orgId = resolveOrgId();
validateConnectorBelongsToTenant(connectorId);
logtoClient.unlinkSsoConnectorFromOrg(orgId, connectorId);
logtoClient.deleteSsoConnector(connectorId);
log.info("Deleted SSO connector {} from org {}", connectorId, orgId);
auditService.log(actorId, null, TenantContext.getTenantId(), AuditAction.SSO_CONNECTOR_DELETED,
connectorId, null, null, "SUCCESS", null);
}
/** Test an SSO connector by fetching its details (validates provider metadata). */