feat: certificate management with stage/activate/restore lifecycle
Provider-based architecture (Docker now, K8s later): - CertificateManager interface + DockerCertificateManager (file-based) - Atomic swap via .wip files for safe cert replacement - Stage -> Activate -> Archive lifecycle with one-deep rollback - Bootstrap supports user-supplied certs via CERT_FILE/KEY_FILE/CA_FILE - CA bundle aggregates platform + tenant CAs, distributed to containers - Vendor UI: Certificates page with upload, activate, restore, discard - Stale tenant tracking (ca_applied_at) with restart banner - Conditional TLS skip removal when CA bundle exists Includes design spec, migration V012, service + controller tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
package net.siegeln.cameleer.saas.certificate;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record CertValidationResult(
|
||||
boolean valid,
|
||||
List<String> errors,
|
||||
CertificateInfo info
|
||||
) {
|
||||
public static CertValidationResult ok(CertificateInfo info) {
|
||||
return new CertValidationResult(true, List.of(), info);
|
||||
}
|
||||
|
||||
public static CertValidationResult fail(List<String> errors) {
|
||||
return new CertValidationResult(false, errors, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package net.siegeln.cameleer.saas.certificate;
|
||||
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
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.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/vendor/certificates")
|
||||
@PreAuthorize("hasAuthority('SCOPE_platform:admin')")
|
||||
public class CertificateController {
|
||||
|
||||
private final CertificateService certificateService;
|
||||
|
||||
public CertificateController(CertificateService certificateService) {
|
||||
this.certificateService = certificateService;
|
||||
}
|
||||
|
||||
// --- Response types ---
|
||||
|
||||
public record CertificateResponse(
|
||||
UUID id, String status, String subject, String issuer,
|
||||
Instant notBefore, Instant notAfter, String fingerprint,
|
||||
boolean hasCa, boolean selfSigned, Instant activatedAt, Instant archivedAt
|
||||
) {
|
||||
public static CertificateResponse from(CertificateEntity e) {
|
||||
if (e == null) return null;
|
||||
return new CertificateResponse(
|
||||
e.getId(), e.getStatus().name(), e.getSubject(), e.getIssuer(),
|
||||
e.getNotBefore(), e.getNotAfter(), e.getFingerprint(),
|
||||
e.isHasCa(), e.isSelfSigned(), e.getActivatedAt(), e.getArchivedAt()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public record OverviewResponse(
|
||||
CertificateResponse active,
|
||||
CertificateResponse staged,
|
||||
CertificateResponse archived,
|
||||
long staleTenantCount
|
||||
) {}
|
||||
|
||||
public record StageResponse(
|
||||
boolean valid,
|
||||
List<String> errors,
|
||||
CertificateResponse certificate
|
||||
) {}
|
||||
|
||||
// --- Endpoints ---
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<OverviewResponse> getOverview() {
|
||||
var overview = certificateService.getOverview();
|
||||
long stale = certificateService.countStaleTenants();
|
||||
return ResponseEntity.ok(new OverviewResponse(
|
||||
CertificateResponse.from(overview.active()),
|
||||
CertificateResponse.from(overview.staged()),
|
||||
CertificateResponse.from(overview.archived()),
|
||||
stale
|
||||
));
|
||||
}
|
||||
|
||||
@PostMapping("/stage")
|
||||
public ResponseEntity<StageResponse> stage(
|
||||
@RequestParam("cert") MultipartFile certFile,
|
||||
@RequestParam("key") MultipartFile keyFile,
|
||||
@RequestParam(value = "ca", required = false) MultipartFile caFile,
|
||||
@AuthenticationPrincipal Jwt jwt) {
|
||||
try {
|
||||
byte[] certPem = certFile.getBytes();
|
||||
byte[] keyPem = keyFile.getBytes();
|
||||
byte[] caPem = caFile != null ? caFile.getBytes() : null;
|
||||
UUID actorId = resolveActorId(jwt);
|
||||
|
||||
CertValidationResult result = certificateService.stage(certPem, keyPem, caPem, actorId);
|
||||
|
||||
if (!result.valid()) {
|
||||
return ResponseEntity.badRequest().body(
|
||||
new StageResponse(false, result.errors(), null));
|
||||
}
|
||||
|
||||
var overview = certificateService.getOverview();
|
||||
return ResponseEntity.ok(new StageResponse(
|
||||
true, List.of(), CertificateResponse.from(overview.staged())));
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.badRequest().body(
|
||||
new StageResponse(false, List.of(e.getMessage()), null));
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/activate")
|
||||
public ResponseEntity<Void> activate() {
|
||||
certificateService.activate();
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PostMapping("/restore")
|
||||
public ResponseEntity<Void> restore() {
|
||||
try {
|
||||
certificateService.restore();
|
||||
return ResponseEntity.noContent().build();
|
||||
} catch (IllegalStateException e) {
|
||||
return ResponseEntity.badRequest().body(null);
|
||||
}
|
||||
}
|
||||
|
||||
@DeleteMapping("/staged")
|
||||
public ResponseEntity<Void> discardStaged() {
|
||||
certificateService.discardStaged();
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@GetMapping("/stale-tenants")
|
||||
public ResponseEntity<Map<String, Long>> staleTenants() {
|
||||
return ResponseEntity.ok(Map.of("count", certificateService.countStaleTenants()));
|
||||
}
|
||||
|
||||
private UUID resolveActorId(Jwt jwt) {
|
||||
try {
|
||||
return UUID.fromString(jwt.getSubject());
|
||||
} catch (Exception e) {
|
||||
return UUID.nameUUIDFromBytes(jwt.getSubject().getBytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package net.siegeln.cameleer.saas.certificate;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "certificates")
|
||||
public class CertificateEntity {
|
||||
|
||||
public enum Status { ACTIVE, STAGED, ARCHIVED }
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
private UUID id;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "status", nullable = false, length = 10)
|
||||
private Status status;
|
||||
|
||||
@Column(name = "subject", length = 500)
|
||||
private String subject;
|
||||
|
||||
@Column(name = "issuer", length = 500)
|
||||
private String issuer;
|
||||
|
||||
@Column(name = "not_before")
|
||||
private Instant notBefore;
|
||||
|
||||
@Column(name = "not_after")
|
||||
private Instant notAfter;
|
||||
|
||||
@Column(name = "fingerprint", length = 128)
|
||||
private String fingerprint;
|
||||
|
||||
@Column(name = "has_ca", nullable = false)
|
||||
private boolean hasCa;
|
||||
|
||||
@Column(name = "self_signed", nullable = false)
|
||||
private boolean selfSigned;
|
||||
|
||||
@Column(name = "uploaded_by")
|
||||
private UUID uploadedBy;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@Column(name = "activated_at")
|
||||
private Instant activatedAt;
|
||||
|
||||
@Column(name = "archived_at")
|
||||
private Instant archivedAt;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
if (createdAt == null) createdAt = Instant.now();
|
||||
}
|
||||
|
||||
// --- Getters and setters ---
|
||||
|
||||
public UUID getId() { return id; }
|
||||
|
||||
public Status getStatus() { return status; }
|
||||
public void setStatus(Status status) { this.status = status; }
|
||||
|
||||
public String getSubject() { return subject; }
|
||||
public void setSubject(String subject) { this.subject = subject; }
|
||||
|
||||
public String getIssuer() { return issuer; }
|
||||
public void setIssuer(String issuer) { this.issuer = issuer; }
|
||||
|
||||
public Instant getNotBefore() { return notBefore; }
|
||||
public void setNotBefore(Instant notBefore) { this.notBefore = notBefore; }
|
||||
|
||||
public Instant getNotAfter() { return notAfter; }
|
||||
public void setNotAfter(Instant notAfter) { this.notAfter = notAfter; }
|
||||
|
||||
public String getFingerprint() { return fingerprint; }
|
||||
public void setFingerprint(String fingerprint) { this.fingerprint = fingerprint; }
|
||||
|
||||
public boolean isHasCa() { return hasCa; }
|
||||
public void setHasCa(boolean hasCa) { this.hasCa = hasCa; }
|
||||
|
||||
public boolean isSelfSigned() { return selfSigned; }
|
||||
public void setSelfSigned(boolean selfSigned) { this.selfSigned = selfSigned; }
|
||||
|
||||
public UUID getUploadedBy() { return uploadedBy; }
|
||||
public void setUploadedBy(UUID uploadedBy) { this.uploadedBy = uploadedBy; }
|
||||
|
||||
public Instant getCreatedAt() { return createdAt; }
|
||||
|
||||
public Instant getActivatedAt() { return activatedAt; }
|
||||
public void setActivatedAt(Instant activatedAt) { this.activatedAt = activatedAt; }
|
||||
|
||||
public Instant getArchivedAt() { return archivedAt; }
|
||||
public void setArchivedAt(Instant archivedAt) { this.archivedAt = archivedAt; }
|
||||
|
||||
public static CertificateEntity fromInfo(CertificateInfo info, Status status) {
|
||||
var entity = new CertificateEntity();
|
||||
entity.setStatus(status);
|
||||
entity.setSubject(info.subject());
|
||||
entity.setIssuer(info.issuer());
|
||||
entity.setNotBefore(info.notBefore());
|
||||
entity.setNotAfter(info.notAfter());
|
||||
entity.setFingerprint(info.fingerprint());
|
||||
entity.setHasCa(info.hasCaBundle());
|
||||
entity.setSelfSigned(info.selfSigned());
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package net.siegeln.cameleer.saas.certificate;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record CertificateInfo(
|
||||
String subject,
|
||||
String issuer,
|
||||
Instant notBefore,
|
||||
Instant notAfter,
|
||||
boolean hasCaBundle,
|
||||
boolean selfSigned,
|
||||
String fingerprint
|
||||
) {}
|
||||
@@ -0,0 +1,41 @@
|
||||
package net.siegeln.cameleer.saas.certificate;
|
||||
|
||||
/**
|
||||
* Provider interface for certificate file management.
|
||||
* Docker implementation writes to the certs volume.
|
||||
* K8s implementation would manage TLS Secrets.
|
||||
*/
|
||||
public interface CertificateManager {
|
||||
|
||||
boolean isAvailable();
|
||||
|
||||
/** Read metadata of the active certificate from the provider storage. */
|
||||
CertificateInfo getActive();
|
||||
|
||||
/** Read metadata of the staged certificate, or null. */
|
||||
CertificateInfo getStaged();
|
||||
|
||||
/** Read metadata of the archived certificate, or null. */
|
||||
CertificateInfo getArchived();
|
||||
|
||||
/**
|
||||
* Write cert+key+ca to staging area and validate.
|
||||
* Does NOT activate — call {@link #activate()} to promote.
|
||||
*/
|
||||
CertValidationResult stage(byte[] certPem, byte[] keyPem, byte[] caBundlePem);
|
||||
|
||||
/** Promote staged -> active. Moves current active to archive (deleting previous archive). */
|
||||
void activate();
|
||||
|
||||
/** Swap archived <-> active. */
|
||||
void restore();
|
||||
|
||||
/** Delete staged files. */
|
||||
void discardStaged();
|
||||
|
||||
/** Generate a self-signed certificate for the given hostname and store as active. */
|
||||
void generateSelfSigned(String hostname);
|
||||
|
||||
/** Read the current CA bundle bytes, or null if none exists. */
|
||||
byte[] getCaBundle();
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package net.siegeln.cameleer.saas.certificate;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface CertificateRepository extends JpaRepository<CertificateEntity, UUID> {
|
||||
|
||||
Optional<CertificateEntity> findByStatus(CertificateEntity.Status status);
|
||||
|
||||
void deleteByStatus(CertificateEntity.Status status);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package net.siegeln.cameleer.saas.certificate;
|
||||
|
||||
import net.siegeln.cameleer.saas.tenant.TenantRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
public class CertificateService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(CertificateService.class);
|
||||
|
||||
private final CertificateManager certManager;
|
||||
private final CertificateRepository certRepository;
|
||||
private final TenantRepository tenantRepository;
|
||||
|
||||
public CertificateService(CertificateManager certManager,
|
||||
CertificateRepository certRepository,
|
||||
TenantRepository tenantRepository) {
|
||||
this.certManager = certManager;
|
||||
this.certRepository = certRepository;
|
||||
this.tenantRepository = tenantRepository;
|
||||
}
|
||||
|
||||
public record CertificateOverview(
|
||||
CertificateEntity active,
|
||||
CertificateEntity staged,
|
||||
CertificateEntity archived
|
||||
) {}
|
||||
|
||||
public CertificateOverview getOverview() {
|
||||
return new CertificateOverview(
|
||||
certRepository.findByStatus(CertificateEntity.Status.ACTIVE).orElse(null),
|
||||
certRepository.findByStatus(CertificateEntity.Status.STAGED).orElse(null),
|
||||
certRepository.findByStatus(CertificateEntity.Status.ARCHIVED).orElse(null)
|
||||
);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public CertValidationResult stage(byte[] certPem, byte[] keyPem, byte[] caBundlePem, UUID actorId) {
|
||||
if (!certManager.isAvailable()) {
|
||||
return CertValidationResult.fail(List.of("Certificate management is not available"));
|
||||
}
|
||||
|
||||
// Discard any existing staged cert
|
||||
certRepository.findByStatus(CertificateEntity.Status.STAGED).ifPresent(certRepository::delete);
|
||||
|
||||
// Stage files and validate
|
||||
CertValidationResult result = certManager.stage(certPem, keyPem, caBundlePem);
|
||||
if (!result.valid()) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Save metadata to DB
|
||||
var entity = CertificateEntity.fromInfo(result.info(), CertificateEntity.Status.STAGED);
|
||||
entity.setUploadedBy(actorId);
|
||||
certRepository.save(entity);
|
||||
|
||||
log.info("Certificate staged by actor {}: subject={}", actorId, result.info().subject());
|
||||
return result;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void activate() {
|
||||
var staged = certRepository.findByStatus(CertificateEntity.Status.STAGED)
|
||||
.orElseThrow(() -> new IllegalStateException("No staged certificate to activate"));
|
||||
|
||||
// File operations: delete archive files, move active -> archive, move staged -> active
|
||||
certManager.activate();
|
||||
|
||||
// DB: delete archived, active -> archived, staged -> active
|
||||
certRepository.findByStatus(CertificateEntity.Status.ARCHIVED).ifPresent(certRepository::delete);
|
||||
|
||||
certRepository.findByStatus(CertificateEntity.Status.ACTIVE).ifPresent(active -> {
|
||||
active.setStatus(CertificateEntity.Status.ARCHIVED);
|
||||
active.setArchivedAt(Instant.now());
|
||||
certRepository.save(active);
|
||||
});
|
||||
|
||||
staged.setStatus(CertificateEntity.Status.ACTIVE);
|
||||
staged.setActivatedAt(Instant.now());
|
||||
certRepository.save(staged);
|
||||
|
||||
log.info("Certificate activated: subject={}", staged.getSubject());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void restore() {
|
||||
var archived = certRepository.findByStatus(CertificateEntity.Status.ARCHIVED)
|
||||
.orElseThrow(() -> new IllegalStateException("No archived certificate to restore"));
|
||||
|
||||
if (archived.getNotAfter() != null && archived.getNotAfter().isBefore(Instant.now())) {
|
||||
throw new IllegalStateException("Archived certificate has expired and cannot be restored");
|
||||
}
|
||||
|
||||
// File operations: swap active <-> archive
|
||||
certManager.restore();
|
||||
|
||||
// DB: swap statuses
|
||||
var active = certRepository.findByStatus(CertificateEntity.Status.ACTIVE).orElse(null);
|
||||
|
||||
archived.setStatus(CertificateEntity.Status.ACTIVE);
|
||||
archived.setActivatedAt(Instant.now());
|
||||
archived.setArchivedAt(null);
|
||||
certRepository.save(archived);
|
||||
|
||||
if (active != null) {
|
||||
active.setStatus(CertificateEntity.Status.ARCHIVED);
|
||||
active.setArchivedAt(Instant.now());
|
||||
certRepository.save(active);
|
||||
}
|
||||
|
||||
log.info("Certificate restored from archive: subject={}", archived.getSubject());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void discardStaged() {
|
||||
certManager.discardStaged();
|
||||
certRepository.findByStatus(CertificateEntity.Status.STAGED).ifPresent(certRepository::delete);
|
||||
log.info("Staged certificate discarded");
|
||||
}
|
||||
|
||||
/**
|
||||
* Count tenants whose ca_applied_at is before the active cert's activated_at,
|
||||
* meaning they haven't picked up the latest CA bundle.
|
||||
*/
|
||||
public long countStaleTenants() {
|
||||
var active = certRepository.findByStatus(CertificateEntity.Status.ACTIVE).orElse(null);
|
||||
if (active == null || active.getActivatedAt() == null) return 0;
|
||||
return tenantRepository.countByCaAppliedAtBeforeOrCaAppliedAtIsNull(active.getActivatedAt());
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed the DB from the filesystem on startup (for bootstrap-generated certs).
|
||||
*/
|
||||
@Transactional
|
||||
public void seedFromFilesystem() {
|
||||
if (certRepository.findByStatus(CertificateEntity.Status.ACTIVE).isPresent()) {
|
||||
return; // Already seeded
|
||||
}
|
||||
CertificateInfo activeInfo = certManager.getActive();
|
||||
if (activeInfo != null) {
|
||||
var entity = CertificateEntity.fromInfo(activeInfo, CertificateEntity.Status.ACTIVE);
|
||||
entity.setActivatedAt(Instant.now());
|
||||
certRepository.save(entity);
|
||||
log.info("Seeded certificate metadata from filesystem: subject={}", activeInfo.subject());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package net.siegeln.cameleer.saas.certificate;
|
||||
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class CertificateStartupListener {
|
||||
|
||||
private final CertificateService certificateService;
|
||||
|
||||
public CertificateStartupListener(CertificateService certificateService) {
|
||||
this.certificateService = certificateService;
|
||||
}
|
||||
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
public void onReady() {
|
||||
certificateService.seedFromFilesystem();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package net.siegeln.cameleer.saas.provisioning;
|
||||
|
||||
import net.siegeln.cameleer.saas.certificate.CertificateManager;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
@Configuration
|
||||
public class CertificateManagerAutoConfig {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(CertificateManagerAutoConfig.class);
|
||||
|
||||
@Bean
|
||||
CertificateManager certificateManager(
|
||||
@Value("${cameleer.certs.path:/certs}") String certsPath) {
|
||||
Path path = Path.of(certsPath);
|
||||
if (Files.isDirectory(path)) {
|
||||
log.info("Certs directory found at {} — enabling Docker certificate manager", certsPath);
|
||||
return new DockerCertificateManager(path);
|
||||
}
|
||||
log.info("No certs directory at {} — certificate management disabled", certsPath);
|
||||
return new DisabledCertificateManager();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package net.siegeln.cameleer.saas.provisioning;
|
||||
|
||||
import net.siegeln.cameleer.saas.certificate.CertificateInfo;
|
||||
import net.siegeln.cameleer.saas.certificate.CertificateManager;
|
||||
import net.siegeln.cameleer.saas.certificate.CertValidationResult;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* No-op certificate manager when certs directory is not available.
|
||||
*/
|
||||
public class DisabledCertificateManager implements CertificateManager {
|
||||
|
||||
@Override
|
||||
public boolean isAvailable() { return false; }
|
||||
|
||||
@Override
|
||||
public CertificateInfo getActive() { return null; }
|
||||
|
||||
@Override
|
||||
public CertificateInfo getStaged() { return null; }
|
||||
|
||||
@Override
|
||||
public CertificateInfo getArchived() { return null; }
|
||||
|
||||
@Override
|
||||
public CertValidationResult stage(byte[] certPem, byte[] keyPem, byte[] caBundlePem) {
|
||||
return CertValidationResult.fail(List.of("Certificate management is not available"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void activate() {}
|
||||
|
||||
@Override
|
||||
public void restore() {}
|
||||
|
||||
@Override
|
||||
public void discardStaged() {}
|
||||
|
||||
@Override
|
||||
public void generateSelfSigned(String hostname) {}
|
||||
|
||||
@Override
|
||||
public byte[] getCaBundle() { return null; }
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
package net.siegeln.cameleer.saas.provisioning;
|
||||
|
||||
import net.siegeln.cameleer.saas.certificate.CertificateInfo;
|
||||
import net.siegeln.cameleer.saas.certificate.CertificateManager;
|
||||
import net.siegeln.cameleer.saas.certificate.CertValidationResult;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.cert.CertificateFactory;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.security.interfaces.RSAPrivateKey;
|
||||
import java.security.interfaces.RSAPublicKey;
|
||||
import java.security.spec.PKCS8EncodedKeySpec;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
|
||||
public class DockerCertificateManager implements CertificateManager {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(DockerCertificateManager.class);
|
||||
|
||||
private final Path certsDir;
|
||||
|
||||
public DockerCertificateManager(Path certsDir) {
|
||||
this.certsDir = certsDir;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isAvailable() {
|
||||
return Files.isDirectory(certsDir) && Files.isWritable(certsDir);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CertificateInfo getActive() {
|
||||
return readCertInfo(certsDir.resolve("cert.pem"), certsDir.resolve("ca.pem"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public CertificateInfo getStaged() {
|
||||
return readCertInfo(certsDir.resolve("staged/cert.pem"), certsDir.resolve("staged/ca.pem"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public CertificateInfo getArchived() {
|
||||
return readCertInfo(certsDir.resolve("prev/cert.pem"), certsDir.resolve("prev/ca.pem"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public CertValidationResult stage(byte[] certPem, byte[] keyPem, byte[] caBundlePem) {
|
||||
List<String> errors = new ArrayList<>();
|
||||
|
||||
// Parse certificate
|
||||
X509Certificate cert;
|
||||
try {
|
||||
cert = parseCertificate(certPem);
|
||||
} catch (Exception e) {
|
||||
errors.add("Invalid certificate PEM: " + e.getMessage());
|
||||
return CertValidationResult.fail(errors);
|
||||
}
|
||||
|
||||
// Parse private key
|
||||
try {
|
||||
var privateKey = parsePrivateKey(keyPem);
|
||||
// Verify key matches cert
|
||||
if (cert.getPublicKey() instanceof RSAPublicKey rsaPub
|
||||
&& privateKey instanceof RSAPrivateKey rsaPriv) {
|
||||
if (!rsaPub.getModulus().equals(rsaPriv.getModulus())) {
|
||||
errors.add("Private key does not match certificate");
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
errors.add("Invalid private key PEM: " + e.getMessage());
|
||||
}
|
||||
|
||||
// Parse CA bundle if provided
|
||||
if (caBundlePem != null && caBundlePem.length > 0) {
|
||||
try {
|
||||
var certs = parseCertificates(caBundlePem);
|
||||
if (certs.isEmpty()) {
|
||||
errors.add("CA bundle contains no valid certificates");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
errors.add("Invalid CA bundle PEM: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
if (!errors.isEmpty()) {
|
||||
return CertValidationResult.fail(errors);
|
||||
}
|
||||
|
||||
// Write to staged directory
|
||||
try {
|
||||
Path stagedDir = certsDir.resolve("staged");
|
||||
Files.createDirectories(stagedDir);
|
||||
|
||||
writeAtomic(stagedDir.resolve("cert.pem"), certPem);
|
||||
writeAtomic(stagedDir.resolve("key.pem"), keyPem);
|
||||
if (caBundlePem != null && caBundlePem.length > 0) {
|
||||
writeAtomic(stagedDir.resolve("ca.pem"), caBundlePem);
|
||||
} else {
|
||||
Files.deleteIfExists(stagedDir.resolve("ca.pem"));
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to write staged certificate files", e);
|
||||
return CertValidationResult.fail(List.of("Failed to write staged files: " + e.getMessage()));
|
||||
}
|
||||
|
||||
var info = toCertInfo(cert, caBundlePem != null && caBundlePem.length > 0, false);
|
||||
return CertValidationResult.ok(info);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void activate() {
|
||||
try {
|
||||
Path stagedDir = certsDir.resolve("staged");
|
||||
Path prevDir = certsDir.resolve("prev");
|
||||
|
||||
if (!Files.exists(stagedDir.resolve("cert.pem"))) {
|
||||
throw new IllegalStateException("No staged certificate to activate");
|
||||
}
|
||||
|
||||
// Delete existing archive
|
||||
deleteDirectory(prevDir);
|
||||
|
||||
// Move current active -> prev (archive)
|
||||
if (Files.exists(certsDir.resolve("cert.pem"))) {
|
||||
Files.createDirectories(prevDir);
|
||||
moveFile(certsDir.resolve("cert.pem"), prevDir.resolve("cert.pem"));
|
||||
moveFile(certsDir.resolve("key.pem"), prevDir.resolve("key.pem"));
|
||||
if (Files.exists(certsDir.resolve("ca.pem"))) {
|
||||
Files.copy(certsDir.resolve("ca.pem"), prevDir.resolve("ca.pem"));
|
||||
}
|
||||
}
|
||||
|
||||
// Move staged -> active (atomic swap via .wip)
|
||||
writeAtomic(certsDir.resolve("cert.pem"), Files.readAllBytes(stagedDir.resolve("cert.pem")));
|
||||
writeAtomic(certsDir.resolve("key.pem"), Files.readAllBytes(stagedDir.resolve("key.pem")));
|
||||
if (Files.exists(stagedDir.resolve("ca.pem"))) {
|
||||
writeAtomic(certsDir.resolve("ca.pem"), Files.readAllBytes(stagedDir.resolve("ca.pem")));
|
||||
}
|
||||
|
||||
// Clean up staged
|
||||
deleteDirectory(stagedDir);
|
||||
log.info("Certificate activated successfully");
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("Failed to activate certificate", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void restore() {
|
||||
try {
|
||||
Path prevDir = certsDir.resolve("prev");
|
||||
if (!Files.exists(prevDir.resolve("cert.pem"))) {
|
||||
throw new IllegalStateException("No archived certificate to restore");
|
||||
}
|
||||
|
||||
// Swap: active <-> prev using a temp dir
|
||||
Path tempDir = certsDir.resolve("swap-tmp");
|
||||
Files.createDirectories(tempDir);
|
||||
|
||||
// Current active -> temp
|
||||
moveFile(certsDir.resolve("cert.pem"), tempDir.resolve("cert.pem"));
|
||||
moveFile(certsDir.resolve("key.pem"), tempDir.resolve("key.pem"));
|
||||
if (Files.exists(certsDir.resolve("ca.pem"))) {
|
||||
moveFile(certsDir.resolve("ca.pem"), tempDir.resolve("ca.pem"));
|
||||
}
|
||||
|
||||
// Prev -> active
|
||||
writeAtomic(certsDir.resolve("cert.pem"), Files.readAllBytes(prevDir.resolve("cert.pem")));
|
||||
writeAtomic(certsDir.resolve("key.pem"), Files.readAllBytes(prevDir.resolve("key.pem")));
|
||||
if (Files.exists(prevDir.resolve("ca.pem"))) {
|
||||
writeAtomic(certsDir.resolve("ca.pem"), Files.readAllBytes(prevDir.resolve("ca.pem")));
|
||||
} else {
|
||||
Files.deleteIfExists(certsDir.resolve("ca.pem"));
|
||||
}
|
||||
|
||||
// Temp -> prev
|
||||
deleteDirectory(prevDir);
|
||||
Files.createDirectories(prevDir);
|
||||
moveFile(tempDir.resolve("cert.pem"), prevDir.resolve("cert.pem"));
|
||||
moveFile(tempDir.resolve("key.pem"), prevDir.resolve("key.pem"));
|
||||
if (Files.exists(tempDir.resolve("ca.pem"))) {
|
||||
moveFile(tempDir.resolve("ca.pem"), prevDir.resolve("ca.pem"));
|
||||
}
|
||||
|
||||
deleteDirectory(tempDir);
|
||||
log.info("Certificate restored from archive");
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("Failed to restore certificate", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void discardStaged() {
|
||||
try {
|
||||
deleteDirectory(certsDir.resolve("staged"));
|
||||
log.info("Staged certificate discarded");
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("Failed to discard staged certificate", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void generateSelfSigned(String hostname) {
|
||||
try {
|
||||
// Use keytool to generate a self-signed cert, then export to PEM
|
||||
// This is a fallback — the init container normally handles this
|
||||
log.warn("generateSelfSigned called on DockerCertificateManager — " +
|
||||
"this is typically handled by the traefik-certs init container");
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to generate self-signed certificate", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] getCaBundle() {
|
||||
try {
|
||||
Path caPath = certsDir.resolve("ca.pem");
|
||||
return Files.exists(caPath) ? Files.readAllBytes(caPath) : null;
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to read CA bundle: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Internal helpers ---
|
||||
|
||||
private CertificateInfo readCertInfo(Path certPath, Path caPath) {
|
||||
if (!Files.exists(certPath)) return null;
|
||||
try {
|
||||
byte[] certBytes = Files.readAllBytes(certPath);
|
||||
X509Certificate cert = parseCertificate(certBytes);
|
||||
boolean hasCa = Files.exists(caPath);
|
||||
boolean selfSigned = cert.getIssuerX500Principal().equals(cert.getSubjectX500Principal());
|
||||
return toCertInfo(cert, hasCa, selfSigned);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to read cert info from {}: {}", certPath, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private CertificateInfo toCertInfo(X509Certificate cert, boolean hasCa, boolean selfSigned) {
|
||||
try {
|
||||
String fingerprint = HexFormat.ofDelimiter(":").formatHex(
|
||||
MessageDigest.getInstance("SHA-256").digest(cert.getEncoded()));
|
||||
// Auto-detect self-signed
|
||||
if (cert.getIssuerX500Principal().equals(cert.getSubjectX500Principal())) {
|
||||
selfSigned = true;
|
||||
}
|
||||
return new CertificateInfo(
|
||||
cert.getSubjectX500Principal().getName(),
|
||||
cert.getIssuerX500Principal().getName(),
|
||||
cert.getNotBefore().toInstant(),
|
||||
cert.getNotAfter().toInstant(),
|
||||
hasCa,
|
||||
selfSigned,
|
||||
fingerprint
|
||||
);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to extract cert info", e);
|
||||
}
|
||||
}
|
||||
|
||||
static X509Certificate parseCertificate(byte[] pem) throws Exception {
|
||||
CertificateFactory cf = CertificateFactory.getInstance("X.509");
|
||||
return (X509Certificate) cf.generateCertificate(new ByteArrayInputStream(pem));
|
||||
}
|
||||
|
||||
static List<X509Certificate> parseCertificates(byte[] pem) throws Exception {
|
||||
CertificateFactory cf = CertificateFactory.getInstance("X.509");
|
||||
var certs = cf.generateCertificates(new ByteArrayInputStream(pem));
|
||||
return certs.stream().map(c -> (X509Certificate) c).toList();
|
||||
}
|
||||
|
||||
static java.security.PrivateKey parsePrivateKey(byte[] pem) throws Exception {
|
||||
String pemStr = new String(pem);
|
||||
// Extract base64 content between PEM markers (handles Bag Attributes etc.)
|
||||
var matcher = java.util.regex.Pattern
|
||||
.compile("-----BEGIN (?:RSA )?PRIVATE KEY-----(.+?)-----END (?:RSA )?PRIVATE KEY-----",
|
||||
java.util.regex.Pattern.DOTALL)
|
||||
.matcher(pemStr);
|
||||
if (!matcher.find()) {
|
||||
throw new IllegalArgumentException("No private key PEM block found");
|
||||
}
|
||||
String base64 = matcher.group(1).replaceAll("\\s+", "");
|
||||
byte[] decoded = Base64.getDecoder().decode(base64);
|
||||
PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(decoded);
|
||||
return KeyFactory.getInstance("RSA").generatePrivate(spec);
|
||||
}
|
||||
|
||||
private void writeAtomic(Path target, byte[] data) throws IOException {
|
||||
Path wip = target.resolveSibling(target.getFileName() + ".wip");
|
||||
Files.write(wip, data);
|
||||
Files.move(wip, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
|
||||
}
|
||||
|
||||
private void moveFile(Path source, Path target) throws IOException {
|
||||
Files.move(source, target, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
|
||||
private void deleteDirectory(Path dir) throws IOException {
|
||||
if (!Files.exists(dir)) return;
|
||||
try (var walk = Files.walk(dir)) {
|
||||
walk.sorted(java.util.Comparator.reverseOrder())
|
||||
.forEach(p -> {
|
||||
try { Files.deleteIfExists(p); } catch (IOException ignored) {}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -135,7 +135,7 @@ public class DockerTenantProvisioner implements TenantProvisioner {
|
||||
labels.put("cameleer.tenant", slug);
|
||||
labels.put("cameleer.role", "server");
|
||||
|
||||
List<String> env = List.of(
|
||||
var env = new java.util.ArrayList<>(List.of(
|
||||
"SPRING_DATASOURCE_URL=" + props.datasourceUrl(),
|
||||
"SPRING_DATASOURCE_USERNAME=cameleer",
|
||||
"SPRING_DATASOURCE_PASSWORD=cameleer_dev",
|
||||
@@ -146,7 +146,6 @@ public class DockerTenantProvisioner implements TenantProvisioner {
|
||||
"CAMELEER_OIDC_ISSUER_URI=" + props.oidcIssuerUri(),
|
||||
"CAMELEER_OIDC_JWK_SET_URI=" + props.oidcJwkSetUri(),
|
||||
"CAMELEER_OIDC_AUDIENCE=https://api.cameleer.local",
|
||||
"CAMELEER_OIDC_TLS_SKIP_VERIFY=true",
|
||||
"CAMELEER_CORS_ALLOWED_ORIGINS=" + props.corsOrigins(),
|
||||
"CAMELEER_LICENSE_TOKEN=" + req.licenseToken(),
|
||||
"CAMELEER_RUNTIME_ENABLED=true",
|
||||
@@ -157,7 +156,11 @@ public class DockerTenantProvisioner implements TenantProvisioner {
|
||||
// Apps deployed by this server join the tenant network (isolated)
|
||||
"CAMELEER_DOCKER_NETWORK=" + tenantNetwork,
|
||||
"CAMELEER_JAR_DOCKER_VOLUME=cameleer-jars-" + slug
|
||||
);
|
||||
));
|
||||
// If no CA bundle exists, fall back to TLS skip for OIDC (self-signed dev)
|
||||
if (!java.nio.file.Files.exists(java.nio.file.Path.of("/certs/ca.pem"))) {
|
||||
env.add("CAMELEER_OIDC_TLS_SKIP_VERIFY=true");
|
||||
}
|
||||
|
||||
// Primary network = tenant-isolated network
|
||||
HostConfig hostConfig = HostConfig.newHostConfig()
|
||||
@@ -165,7 +168,8 @@ public class DockerTenantProvisioner implements TenantProvisioner {
|
||||
.withNetworkMode(tenantNetwork)
|
||||
.withBinds(
|
||||
new Bind("/var/run/docker.sock", new Volume("/var/run/docker.sock")),
|
||||
new Bind("cameleer-jars-" + slug, new Volume("/data/jars"))
|
||||
new Bind("cameleer-jars-" + slug, new Volume("/data/jars")),
|
||||
new Bind("cameleer-saas_certs", new Volume("/certs"), AccessMode.ro)
|
||||
)
|
||||
.withGroupAdd(List.of("0"));
|
||||
|
||||
|
||||
@@ -58,6 +58,9 @@ public class TenantEntity {
|
||||
@Column(name = "provision_error", columnDefinition = "TEXT")
|
||||
private String provisionError;
|
||||
|
||||
@Column(name = "ca_applied_at")
|
||||
private Instant caAppliedAt;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@@ -97,6 +100,8 @@ public class TenantEntity {
|
||||
public void setServerEndpoint(String serverEndpoint) { this.serverEndpoint = serverEndpoint; }
|
||||
public String getProvisionError() { return provisionError; }
|
||||
public void setProvisionError(String provisionError) { this.provisionError = provisionError; }
|
||||
public Instant getCaAppliedAt() { return caAppliedAt; }
|
||||
public void setCaAppliedAt(Instant caAppliedAt) { this.caAppliedAt = caAppliedAt; }
|
||||
public Instant getCreatedAt() { return createdAt; }
|
||||
public Instant getUpdatedAt() { return updatedAt; }
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package net.siegeln.cameleer.saas.tenant;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
@@ -14,4 +15,5 @@ public interface TenantRepository extends JpaRepository<TenantEntity, UUID> {
|
||||
List<TenantEntity> findByStatus(TenantStatus status);
|
||||
boolean existsBySlug(String slug);
|
||||
boolean existsBySlugAndStatusNot(String slug, TenantStatus status);
|
||||
long countByCaAppliedAtBeforeOrCaAppliedAtIsNull(Instant threshold);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Certificate management: track platform TLS certs and CA bundles
|
||||
CREATE TABLE certificates (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
status VARCHAR(10) NOT NULL CHECK (status IN ('ACTIVE', 'STAGED', 'ARCHIVED')),
|
||||
subject VARCHAR(500),
|
||||
issuer VARCHAR(500),
|
||||
not_before TIMESTAMPTZ,
|
||||
not_after TIMESTAMPTZ,
|
||||
fingerprint VARCHAR(128),
|
||||
has_ca BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
self_signed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
uploaded_by UUID,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
activated_at TIMESTAMPTZ,
|
||||
archived_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
-- Track when each tenant last picked up the CA bundle
|
||||
ALTER TABLE tenants ADD COLUMN ca_applied_at TIMESTAMPTZ;
|
||||
@@ -0,0 +1,218 @@
|
||||
package net.siegeln.cameleer.saas.certificate;
|
||||
|
||||
import net.siegeln.cameleer.saas.tenant.TenantRepository;
|
||||
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.time.Instant;
|
||||
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.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class CertificateServiceTest {
|
||||
|
||||
@Mock
|
||||
private CertificateManager certManager;
|
||||
|
||||
@Mock
|
||||
private CertificateRepository certRepository;
|
||||
|
||||
@Mock
|
||||
private TenantRepository tenantRepository;
|
||||
|
||||
private CertificateService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new CertificateService(certManager, certRepository, tenantRepository);
|
||||
}
|
||||
|
||||
@Test
|
||||
void stage_delegatesToManagerAndSavesEntity() {
|
||||
var info = new CertificateInfo("CN=test", "CN=test", Instant.now(),
|
||||
Instant.now().plusSeconds(86400), false, true, "AA:BB");
|
||||
when(certManager.isAvailable()).thenReturn(true);
|
||||
when(certManager.stage(any(), any(), any()))
|
||||
.thenReturn(CertValidationResult.ok(info));
|
||||
when(certRepository.findByStatus(CertificateEntity.Status.STAGED))
|
||||
.thenReturn(Optional.empty());
|
||||
when(certRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
var result = service.stage("cert".getBytes(), "key".getBytes(), null, UUID.randomUUID());
|
||||
|
||||
assertThat(result.valid()).isTrue();
|
||||
var captor = ArgumentCaptor.forClass(CertificateEntity.class);
|
||||
verify(certRepository).save(captor.capture());
|
||||
assertThat(captor.getValue().getStatus()).isEqualTo(CertificateEntity.Status.STAGED);
|
||||
assertThat(captor.getValue().getSubject()).isEqualTo("CN=test");
|
||||
}
|
||||
|
||||
@Test
|
||||
void stage_discardsExistingStagedFirst() {
|
||||
var existing = new CertificateEntity();
|
||||
when(certManager.isAvailable()).thenReturn(true);
|
||||
when(certRepository.findByStatus(CertificateEntity.Status.STAGED))
|
||||
.thenReturn(Optional.of(existing));
|
||||
when(certManager.stage(any(), any(), any()))
|
||||
.thenReturn(CertValidationResult.fail(List.of("bad cert")));
|
||||
|
||||
service.stage("cert".getBytes(), "key".getBytes(), null, UUID.randomUUID());
|
||||
|
||||
verify(certRepository).delete(existing);
|
||||
}
|
||||
|
||||
@Test
|
||||
void stage_returnsErrorWhenManagerUnavailable() {
|
||||
when(certManager.isAvailable()).thenReturn(false);
|
||||
|
||||
var result = service.stage("cert".getBytes(), "key".getBytes(), null, UUID.randomUUID());
|
||||
|
||||
assertThat(result.valid()).isFalse();
|
||||
assertThat(result.errors()).contains("Certificate management is not available");
|
||||
}
|
||||
|
||||
@Test
|
||||
void activate_promotesStagedToActive() {
|
||||
var staged = new CertificateEntity();
|
||||
staged.setStatus(CertificateEntity.Status.STAGED);
|
||||
staged.setSubject("CN=new");
|
||||
|
||||
when(certRepository.findByStatus(CertificateEntity.Status.STAGED))
|
||||
.thenReturn(Optional.of(staged));
|
||||
when(certRepository.findByStatus(CertificateEntity.Status.ARCHIVED))
|
||||
.thenReturn(Optional.empty());
|
||||
when(certRepository.findByStatus(CertificateEntity.Status.ACTIVE))
|
||||
.thenReturn(Optional.empty());
|
||||
|
||||
service.activate();
|
||||
|
||||
verify(certManager).activate();
|
||||
assertThat(staged.getStatus()).isEqualTo(CertificateEntity.Status.ACTIVE);
|
||||
assertThat(staged.getActivatedAt()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void activate_archivesCurrentActive() {
|
||||
var staged = new CertificateEntity();
|
||||
staged.setStatus(CertificateEntity.Status.STAGED);
|
||||
|
||||
var active = new CertificateEntity();
|
||||
active.setStatus(CertificateEntity.Status.ACTIVE);
|
||||
|
||||
when(certRepository.findByStatus(CertificateEntity.Status.STAGED))
|
||||
.thenReturn(Optional.of(staged));
|
||||
when(certRepository.findByStatus(CertificateEntity.Status.ARCHIVED))
|
||||
.thenReturn(Optional.empty());
|
||||
when(certRepository.findByStatus(CertificateEntity.Status.ACTIVE))
|
||||
.thenReturn(Optional.of(active));
|
||||
|
||||
service.activate();
|
||||
|
||||
assertThat(active.getStatus()).isEqualTo(CertificateEntity.Status.ARCHIVED);
|
||||
assertThat(active.getArchivedAt()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void activate_failsWithNoStagedCert() {
|
||||
when(certRepository.findByStatus(CertificateEntity.Status.STAGED))
|
||||
.thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> service.activate())
|
||||
.isInstanceOf(IllegalStateException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void restore_swapsActiveAndArchived() {
|
||||
var active = new CertificateEntity();
|
||||
active.setStatus(CertificateEntity.Status.ACTIVE);
|
||||
active.setSubject("CN=current");
|
||||
|
||||
var archived = new CertificateEntity();
|
||||
archived.setStatus(CertificateEntity.Status.ARCHIVED);
|
||||
archived.setSubject("CN=previous");
|
||||
// Set notAfter in the future so it's restorable
|
||||
archived.setNotAfter(Instant.now().plusSeconds(86400 * 365));
|
||||
|
||||
when(certRepository.findByStatus(CertificateEntity.Status.ARCHIVED))
|
||||
.thenReturn(Optional.of(archived));
|
||||
when(certRepository.findByStatus(CertificateEntity.Status.ACTIVE))
|
||||
.thenReturn(Optional.of(active));
|
||||
|
||||
service.restore();
|
||||
|
||||
verify(certManager).restore();
|
||||
assertThat(archived.getStatus()).isEqualTo(CertificateEntity.Status.ACTIVE);
|
||||
assertThat(active.getStatus()).isEqualTo(CertificateEntity.Status.ARCHIVED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void restore_failsWhenArchivedExpired() {
|
||||
var archived = new CertificateEntity();
|
||||
archived.setStatus(CertificateEntity.Status.ARCHIVED);
|
||||
archived.setNotAfter(Instant.now().minusSeconds(3600)); // expired
|
||||
|
||||
when(certRepository.findByStatus(CertificateEntity.Status.ARCHIVED))
|
||||
.thenReturn(Optional.of(archived));
|
||||
|
||||
assertThatThrownBy(() -> service.restore())
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("expired");
|
||||
}
|
||||
|
||||
@Test
|
||||
void discardStaged_delegatesAndDeletesEntity() {
|
||||
var staged = new CertificateEntity();
|
||||
when(certRepository.findByStatus(CertificateEntity.Status.STAGED))
|
||||
.thenReturn(Optional.of(staged));
|
||||
|
||||
service.discardStaged();
|
||||
|
||||
verify(certManager).discardStaged();
|
||||
verify(certRepository).delete(staged);
|
||||
}
|
||||
|
||||
@Test
|
||||
void seedFromFilesystem_skipsWhenActiveExists() {
|
||||
when(certRepository.findByStatus(CertificateEntity.Status.ACTIVE))
|
||||
.thenReturn(Optional.of(new CertificateEntity()));
|
||||
|
||||
service.seedFromFilesystem();
|
||||
|
||||
verify(certManager, never()).getActive();
|
||||
}
|
||||
|
||||
@Test
|
||||
void seedFromFilesystem_seedsFromManager() {
|
||||
var info = new CertificateInfo("CN=bootstrap", "CN=bootstrap", Instant.now(),
|
||||
Instant.now().plusSeconds(86400), false, true, "AA:BB");
|
||||
when(certRepository.findByStatus(CertificateEntity.Status.ACTIVE))
|
||||
.thenReturn(Optional.empty());
|
||||
when(certManager.getActive()).thenReturn(info);
|
||||
when(certRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
service.seedFromFilesystem();
|
||||
|
||||
var captor = ArgumentCaptor.forClass(CertificateEntity.class);
|
||||
verify(certRepository).save(captor.capture());
|
||||
assertThat(captor.getValue().getSubject()).isEqualTo("CN=bootstrap");
|
||||
assertThat(captor.getValue().getStatus()).isEqualTo(CertificateEntity.Status.ACTIVE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void countStaleTenants_returnsZeroWhenNoActiveCert() {
|
||||
when(certRepository.findByStatus(CertificateEntity.Status.ACTIVE))
|
||||
.thenReturn(Optional.empty());
|
||||
|
||||
assertThat(service.countStaleTenants()).isZero();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package net.siegeln.cameleer.saas.certificate;
|
||||
|
||||
import net.siegeln.cameleer.saas.provisioning.DockerCertificateManager;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class DockerCertificateManagerTest {
|
||||
|
||||
@TempDir
|
||||
Path certsDir;
|
||||
|
||||
private DockerCertificateManager manager;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
manager = new DockerCertificateManager(certsDir);
|
||||
}
|
||||
|
||||
@Test
|
||||
void isAvailable_returnsTrueForWritableDirectory() {
|
||||
assertThat(manager.isAvailable()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getActive_returnsNullWhenNoCert() {
|
||||
assertThat(manager.getActive()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void stage_validatesAndWritesFiles() throws Exception {
|
||||
byte[][] pair = generateCertAndKey("CN=test.example.com");
|
||||
|
||||
var result = manager.stage(pair[0], pair[1], null);
|
||||
|
||||
assertThat(result.valid()).isTrue();
|
||||
assertThat(result.info()).isNotNull();
|
||||
assertThat(result.info().subject()).contains("test.example.com");
|
||||
assertThat(result.info().selfSigned()).isTrue();
|
||||
assertThat(Files.exists(certsDir.resolve("staged/cert.pem"))).isTrue();
|
||||
assertThat(Files.exists(certsDir.resolve("staged/key.pem"))).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void stage_rejectsInvalidCertPem() {
|
||||
var result = manager.stage("not a cert".getBytes(), "not a key".getBytes(), null);
|
||||
assertThat(result.valid()).isFalse();
|
||||
assertThat(result.errors()).isNotEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void stage_rejectsMismatchedKeyAndCert() throws Exception {
|
||||
byte[][] pair1 = generateCertAndKey("CN=test.example.com");
|
||||
byte[][] pair2 = generateCertAndKey("CN=other.example.com");
|
||||
|
||||
var result = manager.stage(pair1[0], pair2[1], null);
|
||||
assertThat(result.valid()).isFalse();
|
||||
assertThat(result.errors()).anyMatch(e -> e.contains("does not match"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void activateAndRestore_lifecycle() throws Exception {
|
||||
// Set up an initial active cert
|
||||
byte[][] pair1 = generateCertAndKey("CN=first.example.com");
|
||||
Files.write(certsDir.resolve("cert.pem"), pair1[0]);
|
||||
Files.write(certsDir.resolve("key.pem"), pair1[1]);
|
||||
|
||||
// Stage a second cert
|
||||
byte[][] pair2 = generateCertAndKey("CN=second.example.com");
|
||||
var stageResult = manager.stage(pair2[0], pair2[1], null);
|
||||
assertThat(stageResult.valid()).isTrue();
|
||||
|
||||
// Activate: first -> archived, second -> active
|
||||
manager.activate();
|
||||
|
||||
var active = manager.getActive();
|
||||
assertThat(active).isNotNull();
|
||||
assertThat(active.subject()).contains("second.example.com");
|
||||
|
||||
var archived = manager.getArchived();
|
||||
assertThat(archived).isNotNull();
|
||||
assertThat(archived.subject()).contains("first.example.com");
|
||||
|
||||
assertThat(manager.getStaged()).isNull();
|
||||
|
||||
// Restore: swap active <-> archived
|
||||
manager.restore();
|
||||
|
||||
active = manager.getActive();
|
||||
assertThat(active.subject()).contains("first.example.com");
|
||||
|
||||
archived = manager.getArchived();
|
||||
assertThat(archived.subject()).contains("second.example.com");
|
||||
}
|
||||
|
||||
@Test
|
||||
void activate_failsWithNoStagedCert() {
|
||||
assertThatThrownBy(() -> manager.activate())
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("No staged certificate");
|
||||
}
|
||||
|
||||
@Test
|
||||
void restore_failsWithNoArchivedCert() {
|
||||
assertThatThrownBy(() -> manager.restore())
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("No archived certificate");
|
||||
}
|
||||
|
||||
@Test
|
||||
void discardStaged_removesFiles() throws Exception {
|
||||
byte[][] pair = generateCertAndKey("CN=test.example.com");
|
||||
manager.stage(pair[0], pair[1], null);
|
||||
|
||||
assertThat(Files.exists(certsDir.resolve("staged/cert.pem"))).isTrue();
|
||||
|
||||
manager.discardStaged();
|
||||
|
||||
assertThat(Files.exists(certsDir.resolve("staged"))).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void stage_withCaBundle() throws Exception {
|
||||
byte[][] pair = generateCertAndKey("CN=test.example.com");
|
||||
|
||||
// Use the cert itself as a "CA" for testing purposes
|
||||
var result = manager.stage(pair[0], pair[1], pair[0]);
|
||||
|
||||
assertThat(result.valid()).isTrue();
|
||||
assertThat(result.info().hasCaBundle()).isTrue();
|
||||
assertThat(Files.exists(certsDir.resolve("staged/ca.pem"))).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getCaBundle_returnsNullWhenMissing() {
|
||||
assertThat(manager.getCaBundle()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getCaBundle_returnsContentWhenPresent() throws Exception {
|
||||
byte[] caContent = "test-ca-content".getBytes();
|
||||
Files.write(certsDir.resolve("ca.pem"), caContent);
|
||||
|
||||
assertThat(manager.getCaBundle()).isEqualTo(caContent);
|
||||
}
|
||||
|
||||
@Test
|
||||
void activate_deletesExistingArchive() throws Exception {
|
||||
// Create initial active
|
||||
byte[][] pair1 = generateCertAndKey("CN=first.example.com");
|
||||
Files.write(certsDir.resolve("cert.pem"), pair1[0]);
|
||||
Files.write(certsDir.resolve("key.pem"), pair1[1]);
|
||||
|
||||
// Create existing archive
|
||||
Files.createDirectories(certsDir.resolve("prev"));
|
||||
byte[][] pairOld = generateCertAndKey("CN=old.example.com");
|
||||
Files.write(certsDir.resolve("prev/cert.pem"), pairOld[0]);
|
||||
Files.write(certsDir.resolve("prev/key.pem"), pairOld[1]);
|
||||
|
||||
// Stage new cert
|
||||
byte[][] pair2 = generateCertAndKey("CN=second.example.com");
|
||||
manager.stage(pair2[0], pair2[1], null);
|
||||
|
||||
// Activate: old archive should be deleted, first becomes archive
|
||||
manager.activate();
|
||||
|
||||
var archived = manager.getArchived();
|
||||
assertThat(archived).isNotNull();
|
||||
assertThat(archived.subject()).contains("first.example.com");
|
||||
}
|
||||
|
||||
// --- Test helpers: generate self-signed certs via keytool + openssl ---
|
||||
|
||||
/**
|
||||
* Generates a matched cert.pem + key.pem pair using keytool and openssl.
|
||||
* Returns [certPem, keyPem].
|
||||
*/
|
||||
private static byte[][] generateCertAndKey(String cn) throws Exception {
|
||||
Path tmpDir = Files.createTempDirectory("cert-test");
|
||||
Path ks = tmpDir.resolve("keystore.p12");
|
||||
Path certFile = tmpDir.resolve("cert.pem");
|
||||
Path keyFile = tmpDir.resolve("key.pem");
|
||||
|
||||
try {
|
||||
// Generate keystore with self-signed cert
|
||||
exec("keytool", "-genkeypair", "-alias", "test", "-keyalg", "RSA", "-keysize", "2048",
|
||||
"-validity", "365", "-dname", cn, "-storetype", "PKCS12",
|
||||
"-keystore", ks.toString(), "-storepass", "changeit");
|
||||
|
||||
// Export cert to PEM
|
||||
exec("keytool", "-exportcert", "-alias", "test", "-rfc",
|
||||
"-keystore", ks.toString(), "-storepass", "changeit",
|
||||
"-file", certFile.toString());
|
||||
|
||||
// Export key via openssl
|
||||
exec("openssl", "pkcs12", "-in", ks.toString(), "-passin", "pass:changeit",
|
||||
"-nocerts", "-nodes", "-out", keyFile.toString());
|
||||
|
||||
return new byte[][] { Files.readAllBytes(certFile), Files.readAllBytes(keyFile) };
|
||||
} finally {
|
||||
Files.deleteIfExists(ks);
|
||||
Files.deleteIfExists(certFile);
|
||||
Files.deleteIfExists(keyFile);
|
||||
Files.deleteIfExists(tmpDir);
|
||||
}
|
||||
}
|
||||
|
||||
private static void exec(String... cmd) throws Exception {
|
||||
var pb = new ProcessBuilder(cmd);
|
||||
pb.redirectErrorStream(true);
|
||||
var proc = pb.start();
|
||||
proc.getInputStream().readAllBytes(); // consume output
|
||||
int exit = proc.waitFor();
|
||||
if (exit != 0) {
|
||||
throw new RuntimeException("Command failed (exit " + exit + "): " + String.join(" ", cmd));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user