From 34e98ab1764c8c059738f68a7a49eccc7ba9722d Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sat, 4 Apr 2026 17:36:09 +0200 Subject: [PATCH] feat: add environment service with tier enforcement and audit logging Implements EnvironmentService with full CRUD, duplicate slug rejection, tier-based environment count limits, and audit logging for create/update/delete. Adds ENVIRONMENT_CREATE, ENVIRONMENT_UPDATE, ENVIRONMENT_DELETE to AuditAction. Co-Authored-By: Claude Sonnet 4.6 --- .../cameleer/saas/audit/AuditAction.java | 1 + .../saas/environment/EnvironmentService.java | 109 ++++++++++ .../environment/EnvironmentServiceTest.java | 186 ++++++++++++++++++ 3 files changed, 296 insertions(+) create mode 100644 src/main/java/net/siegeln/cameleer/saas/environment/EnvironmentService.java create mode 100644 src/test/java/net/siegeln/cameleer/saas/environment/EnvironmentServiceTest.java diff --git a/src/main/java/net/siegeln/cameleer/saas/audit/AuditAction.java b/src/main/java/net/siegeln/cameleer/saas/audit/AuditAction.java index 874cbff..4387f49 100644 --- a/src/main/java/net/siegeln/cameleer/saas/audit/AuditAction.java +++ b/src/main/java/net/siegeln/cameleer/saas/audit/AuditAction.java @@ -3,6 +3,7 @@ package net.siegeln.cameleer.saas.audit; public enum AuditAction { AUTH_REGISTER, AUTH_LOGIN, AUTH_LOGIN_FAILED, AUTH_LOGOUT, TENANT_CREATE, TENANT_UPDATE, TENANT_SUSPEND, TENANT_REACTIVATE, TENANT_DELETE, + ENVIRONMENT_CREATE, ENVIRONMENT_UPDATE, ENVIRONMENT_DELETE, APP_CREATE, APP_DEPLOY, APP_PROMOTE, APP_ROLLBACK, APP_SCALE, APP_STOP, APP_DELETE, SECRET_CREATE, SECRET_READ, SECRET_UPDATE, SECRET_DELETE, SECRET_ROTATE, CONFIG_UPDATE, diff --git a/src/main/java/net/siegeln/cameleer/saas/environment/EnvironmentService.java b/src/main/java/net/siegeln/cameleer/saas/environment/EnvironmentService.java new file mode 100644 index 0000000..e575ec1 --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/environment/EnvironmentService.java @@ -0,0 +1,109 @@ +package net.siegeln.cameleer.saas.environment; + +import net.siegeln.cameleer.saas.audit.AuditAction; +import net.siegeln.cameleer.saas.audit.AuditService; +import net.siegeln.cameleer.saas.license.LicenseDefaults; +import net.siegeln.cameleer.saas.license.LicenseRepository; +import net.siegeln.cameleer.saas.runtime.RuntimeConfig; +import net.siegeln.cameleer.saas.tenant.Tier; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Service +public class EnvironmentService { + + private final EnvironmentRepository environmentRepository; + private final LicenseRepository licenseRepository; + private final AuditService auditService; + private final RuntimeConfig runtimeConfig; + + public EnvironmentService(EnvironmentRepository environmentRepository, + LicenseRepository licenseRepository, + AuditService auditService, + RuntimeConfig runtimeConfig) { + this.environmentRepository = environmentRepository; + this.licenseRepository = licenseRepository; + this.auditService = auditService; + this.runtimeConfig = runtimeConfig; + } + + public EnvironmentEntity create(UUID tenantId, String slug, String displayName, UUID actorId) { + if (environmentRepository.existsByTenantIdAndSlug(tenantId, slug)) { + throw new IllegalArgumentException("Slug already exists for this tenant: " + slug); + } + + enforceTierLimit(tenantId); + + var entity = new EnvironmentEntity(); + entity.setTenantId(tenantId); + entity.setSlug(slug); + entity.setDisplayName(displayName); + entity.setBootstrapToken(runtimeConfig.getBootstrapToken()); + + var saved = environmentRepository.save(entity); + + auditService.log(actorId, null, tenantId, + AuditAction.ENVIRONMENT_CREATE, slug, + null, null, "SUCCESS", null); + + return saved; + } + + public EnvironmentEntity createDefaultForTenant(UUID tenantId) { + return environmentRepository.findByTenantIdAndSlug(tenantId, "default") + .orElseGet(() -> create(tenantId, "default", "Default", null)); + } + + public List listByTenantId(UUID tenantId) { + return environmentRepository.findByTenantId(tenantId); + } + + public Optional getById(UUID id) { + return environmentRepository.findById(id); + } + + public EnvironmentEntity updateDisplayName(UUID environmentId, String displayName, UUID actorId) { + var entity = environmentRepository.findById(environmentId) + .orElseThrow(() -> new IllegalArgumentException("Environment not found: " + environmentId)); + + entity.setDisplayName(displayName); + var saved = environmentRepository.save(entity); + + auditService.log(actorId, null, entity.getTenantId(), + AuditAction.ENVIRONMENT_UPDATE, entity.getSlug(), + null, null, "SUCCESS", null); + + return saved; + } + + public void delete(UUID environmentId, UUID actorId) { + var entity = environmentRepository.findById(environmentId) + .orElseThrow(() -> new IllegalArgumentException("Environment not found: " + environmentId)); + + if ("default".equals(entity.getSlug())) { + throw new IllegalStateException("Cannot delete the default environment"); + } + + environmentRepository.delete(entity); + + auditService.log(actorId, null, entity.getTenantId(), + AuditAction.ENVIRONMENT_DELETE, entity.getSlug(), + null, null, "SUCCESS", null); + } + + private void enforceTierLimit(UUID tenantId) { + var license = licenseRepository.findFirstByTenantIdAndRevokedAtIsNullOrderByCreatedAtDesc(tenantId); + if (license.isEmpty()) { + throw new IllegalStateException("No active license"); + } + var limits = LicenseDefaults.limitsForTier(Tier.valueOf(license.get().getTier())); + var maxEnvs = (int) limits.getOrDefault("max_environments", 1); + var currentCount = environmentRepository.countByTenantId(tenantId); + if (maxEnvs != -1 && currentCount >= maxEnvs) { + throw new IllegalStateException("Environment limit reached for current tier"); + } + } +} diff --git a/src/test/java/net/siegeln/cameleer/saas/environment/EnvironmentServiceTest.java b/src/test/java/net/siegeln/cameleer/saas/environment/EnvironmentServiceTest.java new file mode 100644 index 0000000..7902ced --- /dev/null +++ b/src/test/java/net/siegeln/cameleer/saas/environment/EnvironmentServiceTest.java @@ -0,0 +1,186 @@ +package net.siegeln.cameleer.saas.environment; + +import net.siegeln.cameleer.saas.audit.AuditAction; +import net.siegeln.cameleer.saas.audit.AuditService; +import net.siegeln.cameleer.saas.license.LicenseEntity; +import net.siegeln.cameleer.saas.license.LicenseRepository; +import net.siegeln.cameleer.saas.runtime.RuntimeConfig; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class EnvironmentServiceTest { + + @Mock + private EnvironmentRepository environmentRepository; + + @Mock + private LicenseRepository licenseRepository; + + @Mock + private AuditService auditService; + + @Mock + private RuntimeConfig runtimeConfig; + + private EnvironmentService environmentService; + + @BeforeEach + void setUp() { + environmentService = new EnvironmentService(environmentRepository, licenseRepository, auditService, runtimeConfig); + } + + @Test + void create_shouldCreateEnvironmentAndLogAudit() { + var tenantId = UUID.randomUUID(); + var actorId = UUID.randomUUID(); + var license = new LicenseEntity(); + license.setTenantId(tenantId); + license.setTier("HIGH"); + + when(environmentRepository.existsByTenantIdAndSlug(tenantId, "prod")).thenReturn(false); + when(licenseRepository.findFirstByTenantIdAndRevokedAtIsNullOrderByCreatedAtDesc(tenantId)) + .thenReturn(Optional.of(license)); + when(environmentRepository.countByTenantId(tenantId)).thenReturn(0L); + when(runtimeConfig.getBootstrapToken()).thenReturn("test-token"); + when(environmentRepository.save(any(EnvironmentEntity.class))).thenAnswer(inv -> inv.getArgument(0)); + + var result = environmentService.create(tenantId, "prod", "Production", actorId); + + assertThat(result.getSlug()).isEqualTo("prod"); + assertThat(result.getDisplayName()).isEqualTo("Production"); + assertThat(result.getTenantId()).isEqualTo(tenantId); + assertThat(result.getBootstrapToken()).isEqualTo("test-token"); + + var actionCaptor = ArgumentCaptor.forClass(AuditAction.class); + verify(auditService).log(any(), any(), any(), actionCaptor.capture(), any(), any(), any(), any(), any()); + assertThat(actionCaptor.getValue()).isEqualTo(AuditAction.ENVIRONMENT_CREATE); + } + + @Test + void create_shouldRejectDuplicateSlug() { + var tenantId = UUID.randomUUID(); + var actorId = UUID.randomUUID(); + + when(environmentRepository.existsByTenantIdAndSlug(tenantId, "prod")).thenReturn(true); + + assertThatThrownBy(() -> environmentService.create(tenantId, "prod", "Production", actorId)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void create_shouldEnforceTierLimit() { + var tenantId = UUID.randomUUID(); + var actorId = UUID.randomUUID(); + var license = new LicenseEntity(); + license.setTenantId(tenantId); + license.setTier("LOW"); + + when(environmentRepository.existsByTenantIdAndSlug(tenantId, "staging")).thenReturn(false); + when(licenseRepository.findFirstByTenantIdAndRevokedAtIsNullOrderByCreatedAtDesc(tenantId)) + .thenReturn(Optional.of(license)); + when(environmentRepository.countByTenantId(tenantId)).thenReturn(1L); + + assertThatThrownBy(() -> environmentService.create(tenantId, "staging", "Staging", actorId)) + .isInstanceOf(IllegalStateException.class); + } + + @Test + void listByTenantId_shouldReturnEnvironments() { + var tenantId = UUID.randomUUID(); + var env1 = new EnvironmentEntity(); + env1.setSlug("default"); + var env2 = new EnvironmentEntity(); + env2.setSlug("prod"); + + when(environmentRepository.findByTenantId(tenantId)).thenReturn(List.of(env1, env2)); + + var result = environmentService.listByTenantId(tenantId); + + assertThat(result).hasSize(2); + assertThat(result).extracting(EnvironmentEntity::getSlug).containsExactly("default", "prod"); + } + + @Test + void getById_shouldReturnEnvironment() { + var id = UUID.randomUUID(); + var env = new EnvironmentEntity(); + env.setSlug("prod"); + + when(environmentRepository.findById(id)).thenReturn(Optional.of(env)); + + var result = environmentService.getById(id); + + assertThat(result).isPresent(); + assertThat(result.get().getSlug()).isEqualTo("prod"); + } + + @Test + void updateDisplayName_shouldUpdateAndLogAudit() { + var environmentId = UUID.randomUUID(); + var actorId = UUID.randomUUID(); + var env = new EnvironmentEntity(); + env.setSlug("prod"); + env.setDisplayName("Old Name"); + env.setTenantId(UUID.randomUUID()); + + when(environmentRepository.findById(environmentId)).thenReturn(Optional.of(env)); + when(environmentRepository.save(any(EnvironmentEntity.class))).thenAnswer(inv -> inv.getArgument(0)); + + var result = environmentService.updateDisplayName(environmentId, "New Name", actorId); + + assertThat(result.getDisplayName()).isEqualTo("New Name"); + + var actionCaptor = ArgumentCaptor.forClass(AuditAction.class); + verify(auditService).log(any(), any(), any(), actionCaptor.capture(), any(), any(), any(), any(), any()); + assertThat(actionCaptor.getValue()).isEqualTo(AuditAction.ENVIRONMENT_UPDATE); + } + + @Test + void delete_shouldRejectDefaultEnvironment() { + var environmentId = UUID.randomUUID(); + var actorId = UUID.randomUUID(); + var env = new EnvironmentEntity(); + env.setSlug("default"); + + when(environmentRepository.findById(environmentId)).thenReturn(Optional.of(env)); + + assertThatThrownBy(() -> environmentService.delete(environmentId, actorId)) + .isInstanceOf(IllegalStateException.class); + } + + @Test + void createDefaultForTenant_shouldCreateWithDefaultSlug() { + var tenantId = UUID.randomUUID(); + var license = new LicenseEntity(); + license.setTenantId(tenantId); + license.setTier("LOW"); + + when(environmentRepository.findByTenantIdAndSlug(tenantId, "default")).thenReturn(Optional.empty()); + when(environmentRepository.existsByTenantIdAndSlug(tenantId, "default")).thenReturn(false); + when(licenseRepository.findFirstByTenantIdAndRevokedAtIsNullOrderByCreatedAtDesc(tenantId)) + .thenReturn(Optional.of(license)); + when(environmentRepository.countByTenantId(tenantId)).thenReturn(0L); + when(runtimeConfig.getBootstrapToken()).thenReturn("test-token"); + when(environmentRepository.save(any(EnvironmentEntity.class))).thenAnswer(inv -> inv.getArgument(0)); + + var result = environmentService.createDefaultForTenant(tenantId); + + assertThat(result.getSlug()).isEqualTo("default"); + assertThat(result.getDisplayName()).isEqualTo("Default"); + } +}