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 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-04 17:36:09 +02:00
parent 8511d10343
commit 34e98ab176
3 changed files with 296 additions and 0 deletions

View File

@@ -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,

View File

@@ -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<EnvironmentEntity> listByTenantId(UUID tenantId) {
return environmentRepository.findByTenantId(tenantId);
}
public Optional<EnvironmentEntity> 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");
}
}
}

View File

@@ -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");
}
}