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:
@@ -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) ---
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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). */
|
||||
|
||||
Reference in New Issue
Block a user